From 6f762edbd3c4e25c72c52e147376c8af37b48c64 Mon Sep 17 00:00:00 2001 From: David Joy Date: Fri, 12 Mar 2021 10:25:55 -0500 Subject: [PATCH] Discussions config UI part 2 - FullScreenModal and Stepper! (#53) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Bumping paragon version to latest. * Modifying event handlers to be named with “on” prefix instead of “Handler” suffix * Tweaking a message id. * Removing “Discussion” prefix from discussions components. Seems unnecessary. * Backing pages & resources view with data handling. It still has the list of pages hard coded. * Adding FullScreenModal and Stepper components. These components are pretty close to their final form. They could benefit from some snapshot tests and such; there isn’t much actual functionality in ‘em. Stepper will get a bit more functionality when we add the dynamic drop shadow behavior. Depending on whether the stepper body is at the top or bottom, drop shadows on the header and footer should appear or disappear to indicate more content exists above or below the viewport. * Moving discussions routes inside PagesAndResources Note that the discussions component has been renamed - that’ll be coming in a subsequent commit. Also trying to get consistent about calling it “discussions” * AppList gets less responsibility The AppList is now a child of the top-level “Discussions” component, so it’s no longer responsible for loading the app list or storing the state for the selected app ID. It’s also given a handler for when an app is selected, and no longer has a button to configure the app. * Fleshing out Discussions component The top-level Discussions component (renamed from DiscussionsRoutes) is now responsible for a lot. - it loads the app list - it keeps track of selected app ID - it has handlers for all the various user actions so they can be coordinated here at the top. - it uses component composition to create the majority of the UI, folding together FullScreenModal and Stepper with its route-based views. * Decomposing the app config form The discussion app config form has been decomposed into a container responsible for loading app data, and a component specifically for the LTI configuration form. In the future, ConfigFormContainer will get a second possible child for the edX Forums app, and will switch between the two forms based on the app being configured. Note that I expect that some of the data loading logic from ConfigFormContainer may be better situated in the Discussions component… everything else is happening there, and it may make sense for it to handle loading the app config data as necessary as well. --- package-lock.json | 239 +++++++++++------- package.json | 2 +- src/CourseAuthoringRoutes.jsx | 6 +- .../full-screen-modal/FullScreenModal.jsx | 30 +++ .../full-screen-modal/FullScreenModalBody.jsx | 14 + .../FullScreenModalHeader.jsx | 34 +++ src/generic/stepper/StepIcon.jsx | 30 +++ src/generic/stepper/StepLine.jsx | 7 + src/generic/stepper/Stepper.jsx | 26 ++ src/generic/stepper/StepperBody.jsx | 25 ++ src/generic/stepper/StepperFooter.jsx | 33 +++ src/generic/stepper/StepperHeader.jsx | 54 ++++ src/pages-and-resources/PagesAndResources.jsx | 83 ++---- src/pages-and-resources/data/api.js | 61 +++++ src/pages-and-resources/data/slice.js | 31 +++ src/pages-and-resources/data/thunks.js | 33 +++ .../{DiscussionAppCard.jsx => AppCard.jsx} | 14 +- .../discussions/AppList.jsx | 63 +++++ .../{DiscussionAppList.scss => AppList.scss} | 0 ...ssionAppList.test.jsx => AppList.test.jsx} | 2 +- .../discussions/ConfigFormContainer.jsx | 46 ++++ .../discussions/DiscussionAppList.jsx | 90 ------- .../discussions/DiscussionConfig.jsx | 46 ---- .../discussions/Discussions.jsx | 150 +++++++++++ .../discussions/DiscussionsRoutes.jsx | 40 --- ...ssionsConfigForm.jsx => LtiConfigForm.jsx} | 46 ++-- src/pages-and-resources/discussions/index.js | 1 + .../discussions/messages.js | 20 +- src/pages-and-resources/index.scss | 2 +- src/store.js | 2 + 30 files changed, 856 insertions(+), 374 deletions(-) create mode 100644 src/generic/full-screen-modal/FullScreenModal.jsx create mode 100644 src/generic/full-screen-modal/FullScreenModalBody.jsx create mode 100644 src/generic/full-screen-modal/FullScreenModalHeader.jsx create mode 100644 src/generic/stepper/StepIcon.jsx create mode 100644 src/generic/stepper/StepLine.jsx create mode 100644 src/generic/stepper/Stepper.jsx create mode 100644 src/generic/stepper/StepperBody.jsx create mode 100644 src/generic/stepper/StepperFooter.jsx create mode 100644 src/generic/stepper/StepperHeader.jsx create mode 100644 src/pages-and-resources/data/api.js create mode 100644 src/pages-and-resources/data/slice.js create mode 100644 src/pages-and-resources/data/thunks.js rename src/pages-and-resources/discussions/{DiscussionAppCard.jsx => AppCard.jsx} (85%) create mode 100644 src/pages-and-resources/discussions/AppList.jsx rename src/pages-and-resources/discussions/{DiscussionAppList.scss => AppList.scss} (100%) rename src/pages-and-resources/discussions/{DiscussionAppList.test.jsx => AppList.test.jsx} (62%) create mode 100644 src/pages-and-resources/discussions/ConfigFormContainer.jsx delete mode 100644 src/pages-and-resources/discussions/DiscussionAppList.jsx delete mode 100644 src/pages-and-resources/discussions/DiscussionConfig.jsx create mode 100644 src/pages-and-resources/discussions/Discussions.jsx delete mode 100644 src/pages-and-resources/discussions/DiscussionsRoutes.jsx rename src/pages-and-resources/discussions/{DiscussionsConfigForm.jsx => LtiConfigForm.jsx} (71%) create mode 100644 src/pages-and-resources/discussions/index.js diff --git a/package-lock.json b/package-lock.json index f2697f368..d9c200880 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3518,13 +3518,14 @@ } }, "@edx/paragon": { - "version": "13.10.0", - "resolved": "https://registry.npmjs.org/@edx/paragon/-/paragon-13.10.0.tgz", - "integrity": "sha512-hC5jNgDCZcpnfW8udBfvH+5d5vMz0AARnSj/6jDdpWCpwvFfJqGuiUhQNIyceJ9HXyBZAadr9qvZcEqpNxVTDQ==", + "version": "13.13.5", + "resolved": "https://registry.npmjs.org/@edx/paragon/-/paragon-13.13.5.tgz", + "integrity": "sha512-6q60Lj5dbzZbcXfNrpHXqn4tKQYf1KmcISOe//un0JTSQr6/LnZjHZoZfmzDaRX/2KEDaLCTqCefvKTpB26qCQ==", "requires": { "@fortawesome/fontawesome-svg-core": "^1.2.30", "@fortawesome/free-solid-svg-icons": "^5.14.0", "@fortawesome/react-fontawesome": "^0.1.11", + "@popperjs/core": "^2.6.0", "airbnb-prop-types": "^2.12.0", "bootstrap": "4.6.0", "classnames": "^2.2.6", @@ -3534,13 +3535,14 @@ "prop-types": "^15.7.2", "react-bootstrap": "^1.2.2", "react-focus-on": "^3.5.0", + "react-popper": "^2.2.4", "react-proptype-conditional-require": "^1.0.4", "react-responsive": "^6.1.1", "react-table": "^7.6.1", "react-transition-group": "^4.0.0", "sanitize-html": "^1.20.0", "tabbable": "^4.0.0", - "uncontrollable": "7.1.1" + "uncontrollable": "7.2.1" }, "dependencies": { "@fortawesome/fontawesome-common-types": { @@ -4821,9 +4823,9 @@ } }, "@popperjs/core": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.6.0.tgz", - "integrity": "sha512-cPqjjzuFWNK3BSKLm0abspP0sp/IGOli4p5I5fKFAzdS8fvjdOwDCfZqAaIiXd9lPkOWi3SUUfZof3hEb7J/uw==" + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.9.0.tgz", + "integrity": "sha512-wjtKehFAIARq2OxK8j3JrggNlEslJfNuSm2ArteIbKyRMts2g0a7KzTxfRVNUM+O0gnBJ2hNV8nWPOYBgI1sew==" }, "@reduxjs/toolkit": { "version": "1.5.0", @@ -4858,9 +4860,9 @@ }, "dependencies": { "lodash": { - "version": "4.17.20", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.20.tgz", - "integrity": "sha512-PlhdFcillOINfeV7Ni6oF1TAEayyZBoZ8bcshTHqOYJYlrqzRK5hagpagky5o4HfCzzd1TRkXPMFq6cKk9rGmA==" + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" } } }, @@ -5501,16 +5503,16 @@ }, "dependencies": { "csstype": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.0.6.tgz", - "integrity": "sha512-+ZAmfyWMT7TiIlzdqJgjMb7S4f1beorDbWbsocyK4RaiqA5RTX3K14bnBWmmA9QEM0gRdsjyyrEmcyga8Zsxmw==" + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.0.7.tgz", + "integrity": "sha512-KxnUB0ZMlnUWCsx2Z8MUsr6qV6ja1w9ArPErJaJaF8a5SOWoHLIszeCTKGRGRgtLgYrs1E8CHkNSP1VZTTPc9g==" } } }, "@types/react-transition-group": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.0.tgz", - "integrity": "sha512-/QfLHGpu+2fQOqQaXh8MG9q03bFENooTb/it4jr5kKaZlDQfWvjqWZg48AwzPVMBHlRuTRAY7hRHCEOXz5kV6w==", + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.1.tgz", + "integrity": "sha512-vIo69qKKcYoJ8wKCJjwSgCTM+z3chw3g18dkrDfVX665tMH7tmbDxEAnPdey4gTlwZz5QuHGzd+hul0OVZDqqQ==", "requires": { "@types/react": "*" } @@ -11464,48 +11466,65 @@ "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==" }, "function.prototype.name": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.3.tgz", - "integrity": "sha512-H51qkbNSp8mtkJt+nyW1gyStBiKZxfRqySNUR99ylq6BPXHKI4SEvIlTKp4odLfjRKJV04DFWMU3G/YRlQOsag==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.4.tgz", + "integrity": "sha512-iqy1pIotY/RmhdFZygSSlW0wko2yxkSCKqsuv4pr8QESohpYyG/Z7B/XXvPRKTJS//960rgguE5mSRUsDdaJrQ==", "requires": { - "call-bind": "^1.0.0", + "call-bind": "^1.0.2", "define-properties": "^1.1.3", - "es-abstract": "^1.18.0-next.1", - "functions-have-names": "^1.2.1" + "es-abstract": "^1.18.0-next.2", + "functions-have-names": "^1.2.2" }, "dependencies": { + "call-bind": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz", + "integrity": "sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==", + "requires": { + "function-bind": "^1.1.1", + "get-intrinsic": "^1.0.2" + } + }, "es-abstract": { - "version": "1.18.0-next.2", - "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.18.0-next.2.tgz", - "integrity": "sha512-Ih4ZMFHEtZupnUh6497zEL4y2+w8+1ljnCyaTa+adcoafI1GOvMwFlDjBLfWR7y9VLfrjRJe9ocuHY1PSR9jjw==", + "version": "1.18.0-next.3", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.18.0-next.3.tgz", + "integrity": "sha512-VMzHx/Bczjg59E6jZOQjHeN3DEoptdhejpARgflAViidlqSpjdq9zA6lKwlhRRs/lOw1gHJv2xkkSFRgvEwbQg==", "requires": { "call-bind": "^1.0.2", "es-to-primitive": "^1.2.1", "function-bind": "^1.1.1", - "get-intrinsic": "^1.0.2", + "get-intrinsic": "^1.1.1", "has": "^1.0.3", - "has-symbols": "^1.0.1", - "is-callable": "^1.2.2", + "has-symbols": "^1.0.2", + "is-callable": "^1.2.3", "is-negative-zero": "^2.0.1", - "is-regex": "^1.1.1", + "is-regex": "^1.1.2", + "is-string": "^1.0.5", "object-inspect": "^1.9.0", "object-keys": "^1.1.1", "object.assign": "^4.1.2", - "string.prototype.trimend": "^1.0.3", - "string.prototype.trimstart": "^1.0.3" + "string.prototype.trimend": "^1.0.4", + "string.prototype.trimstart": "^1.0.4", + "unbox-primitive": "^1.0.0" }, "dependencies": { - "call-bind": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz", - "integrity": "sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==", + "get-intrinsic": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.1.1.tgz", + "integrity": "sha512-kWZrnVM42QCiEA2Ig1bG8zjoIMOgxWwYCEeNdwY6Tv/cOSeGpcoX4pXHfKUxNKVoArnrEr2e9srnAxxGIraS9Q==", "requires": { "function-bind": "^1.1.1", - "get-intrinsic": "^1.0.2" + "has": "^1.0.3", + "has-symbols": "^1.0.1" } } } }, + "has-symbols": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.2.tgz", + "integrity": "sha512-chXa79rL/UC2KlX17jo3vRGz0azaWEx5tGqZg5pO3NUyEJVB17dMruQlzCCOfUvElghKcm5194+BCRvi2Rv/Gw==" + }, "is-callable": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.3.tgz", @@ -11518,17 +11537,6 @@ "requires": { "call-bind": "^1.0.2", "has-symbols": "^1.0.1" - }, - "dependencies": { - "call-bind": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz", - "integrity": "sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==", - "requires": { - "function-bind": "^1.1.1", - "get-intrinsic": "^1.0.2" - } - } } }, "object-inspect": { @@ -11548,20 +11556,20 @@ } }, "string.prototype.trimend": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.3.tgz", - "integrity": "sha512-ayH0pB+uf0U28CtjlLvL7NaohvR1amUvVZk+y3DYb0Ey2PUV5zPkkKy9+U1ndVEIXO8hNg18eIv9Jntbii+dKw==", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.4.tgz", + "integrity": "sha512-y9xCjw1P23Awk8EvTpcyL2NIr1j7wJ39f+k6lvRnSMz+mz9CGz9NYPelDk42kOz6+ql8xjfK8oYzy3jAP5QU5A==", "requires": { - "call-bind": "^1.0.0", + "call-bind": "^1.0.2", "define-properties": "^1.1.3" } }, "string.prototype.trimstart": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.3.tgz", - "integrity": "sha512-oBIBUy5lea5tt0ovtOFiEQaBkoBBkyJhZXzJYrSmDo5IUUqbOPvVezuRs/agBIdZ2p2Eo1FD6bD9USyBLfl3xg==", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.4.tgz", + "integrity": "sha512-jh6e984OBfvxS50tdY2nRZnoC5/mLFKOREQfw8t5yytkoUsJRNxvI/E39qu1sD0OtWI3OC0XgKSmcWwziwYuZw==", "requires": { - "call-bind": "^1.0.0", + "call-bind": "^1.0.2", "define-properties": "^1.1.3" } } @@ -11859,6 +11867,11 @@ } } }, + "has-bigints": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.0.1.tgz", + "integrity": "sha512-LSBS2LjbNBTf6287JEbEzvJgftkF5qFkmCo9hDRpAzKhUOlJ+hx8dd4USs00SgsUNwc4617J9ki5YtEClM2ffA==" + }, "has-flag": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", @@ -13052,6 +13065,11 @@ "integrity": "sha1-d8mYQFJ6qOyxqLppe4BkWnqSap0=", "dev": true }, + "is-bigint": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.0.1.tgz", + "integrity": "sha512-J0ELF4yHFxHy0cmSxZuheDOz2luOdVvqjwmEcj8H/L1JHeuEDSDbeRP+Dk9kFVk5RTFzbucJ2Kb9F7ixY2QaCg==" + }, "is-binary-path": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-1.0.1.tgz", @@ -13061,6 +13079,14 @@ "binary-extensions": "^1.0.0" } }, + "is-boolean-object": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.1.0.tgz", + "integrity": "sha512-a7Uprx8UtD+HWdyYwnD1+ExtTgqQtD2k/1yJgtXP6wnMm8byhkoTZRl+95LLThpzNZJ5aEvi46cdH+ayMFRwmA==", + "requires": { + "call-bind": "^1.0.0" + } + }, "is-buffer": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-2.0.4.tgz", @@ -13315,6 +13341,11 @@ } } }, + "is-number-object": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.0.4.tgz", + "integrity": "sha512-zohwelOAur+5uXtk8O3GPQ1eAcu4ZX3UwxQhUlfFFMNpUd83gXgjbhJh6HmB6LUNV/ieOLQuDwJO3dWJosUeMw==" + }, "is-obj": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-2.0.0.tgz", @@ -13416,8 +13447,7 @@ "is-string": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.0.5.tgz", - "integrity": "sha512-buY6VNRjhQMiF1qWDouloZlQbRhDPCebwxSjxMjxgemYT46YMd2NR0/H+fBhEfWX4A/w9TBJ+ol+okqJKFE6vQ==", - "dev": true + "integrity": "sha512-buY6VNRjhQMiF1qWDouloZlQbRhDPCebwxSjxMjxgemYT46YMd2NR0/H+fBhEfWX4A/w9TBJ+ol+okqJKFE6vQ==" }, "is-svg": { "version": "3.0.0", @@ -20648,9 +20678,9 @@ } }, "react-bootstrap": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/react-bootstrap/-/react-bootstrap-1.4.3.tgz", - "integrity": "sha512-4tYhk26KRnK0myMEp2wvNjOvnHMwWfa6pWFIiCtj9wewYaTxP7TrCf7MwcIMBgUzyX0SJXx6UbbDG0+hObiXNg==", + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/react-bootstrap/-/react-bootstrap-1.5.1.tgz", + "integrity": "sha512-jbJNGx9n4JvKgxlvT8DLKSeF3VcqnPJXS9LFdzoZusiZCCGoYecZ9qSCBH5n2A+kjmuura9JkvxI9l7HD+bIdQ==", "requires": { "@babel/runtime": "^7.4.2", "@restart/context": "^2.1.4", @@ -20666,7 +20696,7 @@ "invariant": "^2.2.4", "prop-types": "^15.7.2", "prop-types-extra": "^1.1.0", - "react-overlays": "^4.1.0", + "react-overlays": "^5.0.0", "react-transition-group": "^4.4.1", "uncontrollable": "^7.0.0", "warning": "^4.0.3" @@ -20681,9 +20711,9 @@ }, "dependencies": { "@babel/runtime": { - "version": "7.12.13", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.12.13.tgz", - "integrity": "sha512-8+3UMPBrjFa/6TtKi/7sehPKqfAm4g6K+YQjyyFOLUTxzOngcRZTlAVY8sc2CORJYqdHQY8gRPHmn+qo15rCBw==", + "version": "7.13.9", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.13.9.tgz", + "integrity": "sha512-aY2kU+xgJ3dJ1eU6FMB9EH8dIe8dmusF1xEku52joLvw6eAFN0AI+WxCLDnpev2LEejWBAy2sBvBOBAjI3zmvA==", "requires": { "regenerator-runtime": "^0.13.4" } @@ -21063,9 +21093,9 @@ "integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==" }, "react-overlays": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/react-overlays/-/react-overlays-4.1.1.tgz", - "integrity": "sha512-WtJifh081e6M24KnvTQoNjQEpz7HoLxqt8TwZM7LOYIkYJ8i/Ly1Xi7RVte87ZVnmqQ4PFaFiNHZhSINPSpdBQ==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/react-overlays/-/react-overlays-5.0.0.tgz", + "integrity": "sha512-TKbqfAv23TFtCJ2lzISdx76p97G/DP8Rp4TOFdqM9n8GTruVYgE3jX7Zgb8+w7YJ18slTVcDTQ1/tFzdCqjVhA==", "requires": { "@babel/runtime": "^7.12.1", "@popperjs/core": "^2.5.3", @@ -21078,17 +21108,17 @@ }, "dependencies": { "@babel/runtime": { - "version": "7.12.13", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.12.13.tgz", - "integrity": "sha512-8+3UMPBrjFa/6TtKi/7sehPKqfAm4g6K+YQjyyFOLUTxzOngcRZTlAVY8sc2CORJYqdHQY8gRPHmn+qo15rCBw==", + "version": "7.13.9", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.13.9.tgz", + "integrity": "sha512-aY2kU+xgJ3dJ1eU6FMB9EH8dIe8dmusF1xEku52joLvw6eAFN0AI+WxCLDnpev2LEejWBAy2sBvBOBAjI3zmvA==", "requires": { "regenerator-runtime": "^0.13.4" } }, "csstype": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.0.6.tgz", - "integrity": "sha512-+ZAmfyWMT7TiIlzdqJgjMb7S4f1beorDbWbsocyK4RaiqA5RTX3K14bnBWmmA9QEM0gRdsjyyrEmcyga8Zsxmw==" + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.0.7.tgz", + "integrity": "sha512-KxnUB0ZMlnUWCsx2Z8MUsr6qV6ja1w9ArPErJaJaF8a5SOWoHLIszeCTKGRGRgtLgYrs1E8CHkNSP1VZTTPc9g==" }, "dom-helpers": { "version": "5.2.0", @@ -21101,6 +21131,22 @@ } } }, + "react-popper": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/react-popper/-/react-popper-2.2.4.tgz", + "integrity": "sha512-NacOu4zWupdQjVXq02XpTD3yFPSfg5a7fex0wa3uGKVkFK7UN6LvVxgcb+xYr56UCuWiNPMH20tntdVdJRwYew==", + "requires": { + "react-fast-compare": "^3.0.1", + "warning": "^4.0.2" + }, + "dependencies": { + "react-fast-compare": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.0.tgz", + "integrity": "sha512-rtGImPZ0YyLrscKI9xTpV8psd6I8VAtjKCzQDlzyDvqJA8XOW78TXYQwNRNd8g8JZnDu8q9Fu/1v4HPAVwVdHA==" + } + } + }, "react-proptype-conditional-require": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/react-proptype-conditional-require/-/react-proptype-conditional-require-1.0.4.tgz", @@ -24227,6 +24273,17 @@ "integrity": "sha512-6OSu9PTIzmn9TCDiovULTnET6BgXtDYL4Gg4szY+cGsc3JP1dQL8qvE8kShTRx1NIw4Q9IBHlwODjkjWEtMUyA==", "dev": true }, + "unbox-primitive": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.0.tgz", + "integrity": "sha512-P/51NX+JXyxK/aigg1/ZgyccdAxm5K1+n8+tvqSntjOivPt19gvm1VC49RWYetsiub8WViUchdxl/KWHHB0kzA==", + "requires": { + "function-bind": "^1.1.1", + "has-bigints": "^1.0.0", + "has-symbols": "^1.0.0", + "which-boxed-primitive": "^1.0.1" + } + }, "unbzip2-stream": { "version": "1.4.3", "resolved": "https://registry.npmjs.org/unbzip2-stream/-/unbzip2-stream-1.4.3.tgz", @@ -24239,30 +24296,14 @@ } }, "uncontrollable": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/uncontrollable/-/uncontrollable-7.1.1.tgz", - "integrity": "sha512-EcPYhot3uWTS3w00R32R2+vS8Vr53tttrvMj/yA1uYRhf8hbTG2GyugGqWDY0qIskxn0uTTojVd6wPYW9ZEf8Q==", + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/uncontrollable/-/uncontrollable-7.2.1.tgz", + "integrity": "sha512-svtcfoTADIB0nT9nltgjujTi7BzVmwjZClOmskKu/E8FW9BXzg9os8OLr4f8Dlnk0rYWJIWr4wv9eKUXiQvQwQ==", "requires": { "@babel/runtime": "^7.6.3", - "@types/react": "^16.9.11", + "@types/react": ">=16.9.11", "invariant": "^2.2.4", "react-lifecycles-compat": "^3.0.4" - }, - "dependencies": { - "@types/react": { - "version": "16.14.4", - "resolved": "https://registry.npmjs.org/@types/react/-/react-16.14.4.tgz", - "integrity": "sha512-ETj7GbkPGjca/A4trkVeGvoIakmLV6ZtX3J8dcmOpzKzWVybbrOxanwaIPG71GZwImoMDY6Fq4wIe34lEqZ0FQ==", - "requires": { - "@types/prop-types": "*", - "csstype": "^3.0.2" - } - }, - "csstype": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.0.6.tgz", - "integrity": "sha512-+ZAmfyWMT7TiIlzdqJgjMb7S4f1beorDbWbsocyK4RaiqA5RTX3K14bnBWmmA9QEM0gRdsjyyrEmcyga8Zsxmw==" - } } }, "unicode-canonical-property-names-ecmascript": { @@ -25566,6 +25607,18 @@ "isexe": "^2.0.0" } }, + "which-boxed-primitive": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.0.2.tgz", + "integrity": "sha512-bwZdv0AKLpplFY2KZRX6TvyuN7ojjr7lwkg6ml0roIy9YeuSr7JS372qlNW18UQYzgYK9ziGcerWqZOmEn9VNg==", + "requires": { + "is-bigint": "^1.0.1", + "is-boolean-object": "^1.1.0", + "is-number-object": "^1.0.4", + "is-string": "^1.0.5", + "is-symbol": "^1.0.3" + } + }, "which-module": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.0.tgz", diff --git a/package.json b/package.json index af9bbd4b3..7662e5eae 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,7 @@ "@edx/brand": "npm:@edx/brand-openedx@1.1.0", "@edx/frontend-component-footer": "10.0.11", "@edx/frontend-platform": "1.8.0", - "@edx/paragon": "13.10.0", + "@edx/paragon": "13.13.5", "@fortawesome/fontawesome-svg-core": "1.2.28", "@fortawesome/free-brands-svg-icons": "5.11.2", "@fortawesome/free-regular-svg-icons": "5.11.2", diff --git a/src/CourseAuthoringRoutes.jsx b/src/CourseAuthoringRoutes.jsx index d9b94c906..4e3d75ec1 100644 --- a/src/CourseAuthoringRoutes.jsx +++ b/src/CourseAuthoringRoutes.jsx @@ -6,7 +6,6 @@ import { PageRoute } from '@edx/frontend-platform/react'; import CourseAuthoringPage from './CourseAuthoringPage'; import { PagesAndResources } from './pages-and-resources'; import ProctoredExamSettings from './proctored-exam-settings/ProctoredExamSettings'; -import DiscussionsRoutes from './pages-and-resources/discussions/DiscussionsRoutes'; /** * As of this writing, these routes are mounted at a path prefixed with the following: @@ -28,12 +27,9 @@ export default function CourseAuthoringRoutes({ courseId }) { return ( - + - - - diff --git a/src/generic/full-screen-modal/FullScreenModal.jsx b/src/generic/full-screen-modal/FullScreenModal.jsx new file mode 100644 index 000000000..33c2a7e7d --- /dev/null +++ b/src/generic/full-screen-modal/FullScreenModal.jsx @@ -0,0 +1,30 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { ModalLayer } from '@edx/paragon'; +import classNames from 'classnames'; + +export default function FullScreenModal({ children, title, onClose }) { + return ( + +
+ {children} +
+
+ ); +} + +FullScreenModal.propTypes = { + children: PropTypes.node.isRequired, + title: PropTypes.string.isRequired, + onClose: PropTypes.func.isRequired, +}; diff --git a/src/generic/full-screen-modal/FullScreenModalBody.jsx b/src/generic/full-screen-modal/FullScreenModalBody.jsx new file mode 100644 index 000000000..43ae47876 --- /dev/null +++ b/src/generic/full-screen-modal/FullScreenModalBody.jsx @@ -0,0 +1,14 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +export default function FullScreenModalBody({ children }) { + return ( +
+ {children} +
+ ); +} + +FullScreenModalBody.propTypes = { + children: PropTypes.node.isRequired, +}; diff --git a/src/generic/full-screen-modal/FullScreenModalHeader.jsx b/src/generic/full-screen-modal/FullScreenModalHeader.jsx new file mode 100644 index 000000000..3fe4f4a52 --- /dev/null +++ b/src/generic/full-screen-modal/FullScreenModalHeader.jsx @@ -0,0 +1,34 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { ModalCloseButton } from '@edx/paragon'; +import { Close } from '@edx/paragon/icons'; +import classNames from 'classnames'; + +export default function FullScreenModalHeader({ className, title }) { + return ( +
+

{title}

+ + + +
+ ); +} + +FullScreenModalHeader.propTypes = { + className: PropTypes.string, + title: PropTypes.string.isRequired, +}; + +FullScreenModalHeader.defaultProps = { + className: null, +}; diff --git a/src/generic/stepper/StepIcon.jsx b/src/generic/stepper/StepIcon.jsx new file mode 100644 index 000000000..03c9f0b48 --- /dev/null +++ b/src/generic/stepper/StepIcon.jsx @@ -0,0 +1,30 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +export default function StepIcon({ children, size }) { + return ( +
+ {children} +
+ ); +} + +StepIcon.propTypes = { + children: PropTypes.node.isRequired, + size: PropTypes.string, +}; + +StepIcon.defaultProps = { + size: '1.3rem', +}; diff --git a/src/generic/stepper/StepLine.jsx b/src/generic/stepper/StepLine.jsx new file mode 100644 index 000000000..5c084a7af --- /dev/null +++ b/src/generic/stepper/StepLine.jsx @@ -0,0 +1,7 @@ +import React from 'react'; + +export default function StepLine() { + return ( +
+ ); +} diff --git a/src/generic/stepper/Stepper.jsx b/src/generic/stepper/Stepper.jsx new file mode 100644 index 000000000..96cecd8e5 --- /dev/null +++ b/src/generic/stepper/Stepper.jsx @@ -0,0 +1,26 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; + +export default function Stepper({ children, className }) { + return ( +
+ {children} +
+ ); +} + +Stepper.propTypes = { + className: PropTypes.string, + children: PropTypes.node.isRequired, +}; + +Stepper.defaultProps = { + className: null, +}; diff --git a/src/generic/stepper/StepperBody.jsx b/src/generic/stepper/StepperBody.jsx new file mode 100644 index 000000000..79db027a4 --- /dev/null +++ b/src/generic/stepper/StepperBody.jsx @@ -0,0 +1,25 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; + +export default function StepperBody({ children, className }) { + return ( +
+ {children} +
+ ); +} + +StepperBody.propTypes = { + children: PropTypes.node.isRequired, + className: PropTypes.string, +}; + +StepperBody.defaultProps = { + className: null, +}; diff --git a/src/generic/stepper/StepperFooter.jsx b/src/generic/stepper/StepperFooter.jsx new file mode 100644 index 000000000..ccb5bffb9 --- /dev/null +++ b/src/generic/stepper/StepperFooter.jsx @@ -0,0 +1,33 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; + +export default function StepperFooter({ children, className }) { + return ( +
+ {children} +
+ ); +} + +StepperFooter.propTypes = { + children: PropTypes.node.isRequired, + className: PropTypes.string, +}; + +StepperFooter.defaultProps = { + className: null, +}; diff --git a/src/generic/stepper/StepperHeader.jsx b/src/generic/stepper/StepperHeader.jsx new file mode 100644 index 000000000..2f816ab71 --- /dev/null +++ b/src/generic/stepper/StepperHeader.jsx @@ -0,0 +1,54 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; +import StepIcon from './StepIcon'; +import StepLine from './StepLine'; + +export default function StepperHeader({ steps, className }) { + return ( +
+ {steps.map(({ iconLabel, label }, index) => { + const isNotLastStep = index < steps.length - 1; + return ( + // eslint-disable-next-line react/no-array-index-key + + + {iconLabel || index + 1} + + {label} + {isNotLastStep && ( + + )} + + ); + })} +
+ ); +} + +StepperHeader.propTypes = { + steps: PropTypes.arrayOf( + PropTypes.shape({ + iconLabel: PropTypes.oneOfType([ + PropTypes.string, + PropTypes.node, + ]), + label: PropTypes.string.isRequired, + }), + ).isRequired, + className: PropTypes.string, +}; + +StepperHeader.defaultProps = { + className: null, +}; diff --git a/src/pages-and-resources/PagesAndResources.jsx b/src/pages-and-resources/PagesAndResources.jsx index d53925a91..9714a2b42 100644 --- a/src/pages-and-resources/PagesAndResources.jsx +++ b/src/pages-and-resources/PagesAndResources.jsx @@ -1,73 +1,33 @@ -import React, { useContext } from 'react'; +import React, { useContext, useEffect } from 'react'; import PropTypes from 'prop-types'; import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; -import { AppContext } from '@edx/frontend-platform/react'; +import { AppContext, PageRoute } from '@edx/frontend-platform/react'; + +import { Switch, useRouteMatch } from 'react-router'; +import { useDispatch, useSelector } from 'react-redux'; import messages from './messages'; +import Discussions from './discussions'; + import PageGrid from './pages/PageGrid'; import ResourceList from './resources/ResourcesList'; -// XXX this is just for testing and should be removed ASAP -const pages = [ - { - id: 'discussion', - title: 'Discussion', - isEnabled: false, - showSettings: false, - showStatus: false, - showEnable: true, - description: 'Encourage participation and engagement in your course with discussion forums', - }, - { - id: 'teams', - title: 'Teams', - isEnabled: true, - showSettings: true, - showStatus: true, - showEnable: false, - description: 'Leverage teams to allow learners to connect by topic of interest', - }, - { - id: 'progress', - title: 'Progress', - isEnabled: false, - showSettings: true, - showStatus: true, - showEnable: false, - description: 'Allow students to track their progress throughout the course lorem ipsum', - }, - { - id: 'textbooks', - title: 'Textbooks', - isEnabled: true, - showSettings: true, - showStatus: true, - showEnable: false, - description: 'Provide links to applicable resources for your course', - }, - { - id: 'notes', - title: 'Notes', - isEnabled: true, - showSettings: true, - showStatus: true, - showEnable: false, - description: 'Support individual note taking that is visible only to the students', - }, - { - id: 'wiki', - title: 'Wiki', - isEnabled: false, - showSettings: false, - showStatus: false, - showEnable: true, - description: 'Share your wiki content to provide additional course material', - }, -]; +import { fetchPages } from './data/thunks'; +import { useModels } from '../generic/model-store'; function PagesAndResources({ courseId, intl }) { + const { path } = useRouteMatch(); const { config } = useContext(AppContext); const lmsCourseURL = `${config.LMS_BASE_URL}/courses/${courseId}`; + + const dispatch = useDispatch(); + useEffect(() => { + dispatch(fetchPages(courseId)); + }, [courseId]); + + const pageIds = useSelector(state => state.pagesAndResources.pageIds); + const pages = useModels('pages', pageIds); + return (
@@ -80,6 +40,11 @@ function PagesAndResources({ courseId, intl }) {
+ + + + +
); } diff --git a/src/pages-and-resources/data/api.js b/src/pages-and-resources/data/api.js new file mode 100644 index 000000000..75d725b94 --- /dev/null +++ b/src/pages-and-resources/data/api.js @@ -0,0 +1,61 @@ +/* eslint-disable import/prefer-default-export */ +export function getPages() { + return Promise.resolve({ + pages: [ + { + id: 'discussions', + title: 'Discussions', + isEnabled: false, + showSettings: false, + showStatus: false, + showEnable: true, + description: 'Encourage participation and engagement in your course with discussion forums', + }, + { + id: 'teams', + title: 'Teams', + isEnabled: true, + showSettings: true, + showStatus: true, + showEnable: false, + description: 'Leverage teams to allow learners to connect by topic of interest', + }, + { + id: 'progress', + title: 'Progress', + isEnabled: false, + showSettings: true, + showStatus: true, + showEnable: false, + description: 'Allow students to track their progress throughout the course lorem ipsum', + }, + { + id: 'textbooks', + title: 'Textbooks', + isEnabled: true, + showSettings: true, + showStatus: true, + showEnable: false, + description: 'Provide links to applicable resources for your course', + }, + { + id: 'notes', + title: 'Notes', + isEnabled: true, + showSettings: true, + showStatus: true, + showEnable: false, + description: 'Support individual note taking that is visible only to the students', + }, + { + id: 'wiki', + title: 'Wiki', + isEnabled: false, + showSettings: false, + showStatus: false, + showEnable: true, + description: 'Share your wiki content to provide additional course material', + }, + ], + }); +} diff --git a/src/pages-and-resources/data/slice.js b/src/pages-and-resources/data/slice.js new file mode 100644 index 000000000..a2c8d2de5 --- /dev/null +++ b/src/pages-and-resources/data/slice.js @@ -0,0 +1,31 @@ +/* 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: 'pagesAndResources', + initialState: { + pageIds: [], + status: LOADING, + }, + reducers: { + fetchPagesSuccess: (state, { payload }) => { + state.pageIds = payload.pageIds; + }, + updateStatus: (state, { payload }) => { + state.status = payload.status; + }, + }, +}); + +export const { + fetchPagesSuccess, + updateStatus, +} = slice.actions; + +export const { + reducer, +} = slice; diff --git a/src/pages-and-resources/data/thunks.js b/src/pages-and-resources/data/thunks.js new file mode 100644 index 000000000..046df678f --- /dev/null +++ b/src/pages-and-resources/data/thunks.js @@ -0,0 +1,33 @@ +import { + getPages, +} from './api'; +import { addModels } from '../../generic/model-store'; +import { + FAILED, + fetchPagesSuccess, + LOADING, + updateStatus, + LOADED, +} from './slice'; + +/* eslint-disable import/prefer-default-export */ +export function fetchPages(courseId) { + return async (dispatch) => { + dispatch(updateStatus({ courseId, status: LOADING })); + + try { + const { pages } = await getPages(courseId); + + dispatch(addModels({ modelType: 'pages', models: pages })); + dispatch(fetchPagesSuccess({ + pageIds: pages.map(page => page.id), + })); + dispatch(updateStatus({ courseId, status: LOADED })); + } catch (error) { + // TODO: We need generic error handling in the app for when a request just fails... in other + // parts of the app (proctored exam settings) we show a nice message and ask the user to + // reload/try again later. + dispatch(updateStatus({ courseId, status: FAILED })); + } + }; +} diff --git a/src/pages-and-resources/discussions/DiscussionAppCard.jsx b/src/pages-and-resources/discussions/AppCard.jsx similarity index 85% rename from src/pages-and-resources/discussions/DiscussionAppCard.jsx rename to src/pages-and-resources/discussions/AppCard.jsx index a40dd62b6..c272aa833 100644 --- a/src/pages-and-resources/discussions/DiscussionAppCard.jsx +++ b/src/pages-and-resources/discussions/AppCard.jsx @@ -8,15 +8,15 @@ import { faLock } from '@fortawesome/free-solid-svg-icons'; import messages from './messages'; -function DiscussionAppCard({ - app, clickHandler, intl, selected, +function AppCard({ + app, onClick, intl, selected, }) { return ( { if (app.isAvailable) { clickHandler(app.id); } }} - onKeyPress={() => { if (app.isAvailable) { clickHandler(app.id); } }} + onClick={() => { if (app.isAvailable) { onClick(app.id); } }} + onKeyPress={() => { if (app.isAvailable) { onClick(app.id); } }} role="radio" aria-checked={selected} style={{ @@ -63,7 +63,7 @@ function DiscussionAppCard({ ); } -DiscussionAppCard.propTypes = { +AppCard.propTypes = { app: PropTypes.shape({ description: PropTypes.string.isRequired, id: PropTypes.string.isRequired, @@ -72,9 +72,9 @@ DiscussionAppCard.propTypes = { name: PropTypes.string.isRequired, supportLevel: PropTypes.string.isRequired, }).isRequired, - clickHandler: PropTypes.func.isRequired, + onClick: PropTypes.func.isRequired, selected: PropTypes.bool.isRequired, intl: intlShape.isRequired, }; -export default injectIntl(DiscussionAppCard); +export default injectIntl(AppCard); diff --git a/src/pages-and-resources/discussions/AppList.jsx b/src/pages-and-resources/discussions/AppList.jsx new file mode 100644 index 000000000..472246827 --- /dev/null +++ b/src/pages-and-resources/discussions/AppList.jsx @@ -0,0 +1,63 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; +import { CardGrid } from '@edx/paragon'; +import { useSelector } from 'react-redux'; + +import { useModels } from '../../generic/model-store'; + +import AppCard from './AppCard'; +import messages from './messages'; +import FeaturesTable from './FeaturesTable'; + +function AppList({ + intl, onSelectApp, selectedAppId, +}) { + const appIds = useSelector(state => state.discussions.appIds); + const featureIds = useSelector(state => state.discussions.featureIds); + const apps = useModels('apps', appIds); + const features = useModels('features', featureIds); + + return ( +
+

{intl.formatMessage(messages.heading)}

+ + {apps.map(app => ( + + ))} + + +

+ {intl.formatMessage(messages.supportedFeatures)} +

+ + +
+ ); +} + +AppList.propTypes = { + intl: intlShape.isRequired, + onSelectApp: PropTypes.func.isRequired, + selectedAppId: PropTypes.string, +}; + +AppList.defaultProps = { + selectedAppId: null, +}; + +export default injectIntl(AppList); diff --git a/src/pages-and-resources/discussions/DiscussionAppList.scss b/src/pages-and-resources/discussions/AppList.scss similarity index 100% rename from src/pages-and-resources/discussions/DiscussionAppList.scss rename to src/pages-and-resources/discussions/AppList.scss diff --git a/src/pages-and-resources/discussions/DiscussionAppList.test.jsx b/src/pages-and-resources/discussions/AppList.test.jsx similarity index 62% rename from src/pages-and-resources/discussions/DiscussionAppList.test.jsx rename to src/pages-and-resources/discussions/AppList.test.jsx index f8961aa71..ed9dc000a 100644 --- a/src/pages-and-resources/discussions/DiscussionAppList.test.jsx +++ b/src/pages-and-resources/discussions/AppList.test.jsx @@ -1,4 +1,4 @@ -describe('DiscussionAppList', () => { +describe('AppList', () => { it('will pass because it is an example', () => { }); diff --git a/src/pages-and-resources/discussions/ConfigFormContainer.jsx b/src/pages-and-resources/discussions/ConfigFormContainer.jsx new file mode 100644 index 000000000..628325557 --- /dev/null +++ b/src/pages-and-resources/discussions/ConfigFormContainer.jsx @@ -0,0 +1,46 @@ +import React, { useEffect } from 'react'; +import PropTypes from 'prop-types'; +import { useDispatch, useSelector } from 'react-redux'; + +import { useRouteMatch } from 'react-router'; +import { useModel } from '../../generic/model-store'; + +import LtiConfigForm from './LtiConfigForm'; +import { fetchAppConfig } from './data/thunks'; + +// eslint-disable-next-line no-unused-vars +export default function ConfigFormContainer({ + courseId, onSubmit, formRef, +}) { + const { params: { appId: routeAppId } } = useRouteMatch(); + + const dispatch = useDispatch(); + useEffect(() => { + dispatch(fetchAppConfig(courseId, routeAppId)); + }, [courseId]); + + const { activeAppId, activeAppConfigId } = useSelector(state => state.discussions); + + const app = useModel('apps', activeAppId); + const appConfig = useModel('appConfigs', activeAppConfigId); + + if (!appConfig || !app) { + return null; + } + + return ( + + ); +} + +ConfigFormContainer.propTypes = { + courseId: PropTypes.string.isRequired, + onSubmit: PropTypes.func.isRequired, + // eslint-disable-next-line react/forbid-prop-types + formRef: PropTypes.object.isRequired, +}; diff --git a/src/pages-and-resources/discussions/DiscussionAppList.jsx b/src/pages-and-resources/discussions/DiscussionAppList.jsx deleted file mode 100644 index 3c2c8999b..000000000 --- a/src/pages-and-resources/discussions/DiscussionAppList.jsx +++ /dev/null @@ -1,90 +0,0 @@ -import React, { useCallback, useEffect, useState } from 'react'; -import PropTypes from 'prop-types'; -import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; -import { - Button, CardGrid, -} from '@edx/paragon'; -import { useDispatch, useSelector } from 'react-redux'; -import { history } from '@edx/frontend-platform'; -import { useLocation } from 'react-router'; - -import { useModel, useModels } from '../../generic/model-store'; - -import messages from './messages'; -import { fetchApps } from './data/thunks'; -import DiscussionAppCard from './DiscussionAppCard'; -import FeaturesTable from './FeaturesTable'; - -function DiscussionAppList({ courseId, intl }) { - const [selectedAppId, setSelectedAppId] = useState(null); - const { pathname } = useLocation(); - - const dispatch = useDispatch(); - useEffect(() => { - dispatch(fetchApps(courseId)); - }, [courseId]); - - const appIds = useSelector(state => state.discussions.appIds); - const featureIds = useSelector(state => state.discussions.featureIds); - const apps = useModels('apps', appIds); - const features = useModels('features', featureIds); - - const selectedApp = useModel('apps', selectedAppId); - - const handleSelectApp = useCallback((appId) => { - if (selectedAppId === appId) { - setSelectedAppId(null); - } else { - setSelectedAppId(appId); - } - }, [selectedAppId]); - - const handleConfigureApp = () => { - history.push(`${pathname}/configure/${selectedAppId}`); - }; - - return ( -
-

{intl.formatMessage(messages.heading)}

- - {apps.map(app => ( - - ))} - - -
-

- {intl.formatMessage(messages.supportedFeatures)} -

- {selectedAppId && ( - - )} -
- - -
- ); -} - -DiscussionAppList.propTypes = { - courseId: PropTypes.string.isRequired, - intl: intlShape.isRequired, -}; - -export default injectIntl(DiscussionAppList); diff --git a/src/pages-and-resources/discussions/DiscussionConfig.jsx b/src/pages-and-resources/discussions/DiscussionConfig.jsx deleted file mode 100644 index 22bedfa2b..000000000 --- a/src/pages-and-resources/discussions/DiscussionConfig.jsx +++ /dev/null @@ -1,46 +0,0 @@ -import React, { useCallback, useEffect } from 'react'; -import PropTypes from 'prop-types'; -import { useRouteMatch } from 'react-router'; -import { useDispatch, useSelector } from 'react-redux'; - -import { history } from '@edx/frontend-platform'; -import { useModel } from '../../generic/model-store'; -import { fetchAppConfig, saveAppConfig } from './data/thunks'; -import DiscussionsConfigForm from './DiscussionsConfigForm'; - -export default function DiscussionConfig({ courseId }) { - const { params: { appId } } = useRouteMatch(); - - const dispatch = useDispatch(); - useEffect(() => { - dispatch(fetchAppConfig(courseId, appId)); - }, [courseId]); - - const { activeAppId, activeAppConfigId } = useSelector(state => state.discussions); - - const app = useModel('apps', activeAppId); - const appConfig = useModel('appConfigs', activeAppConfigId); - - const handleSubmit = useCallback((values) => { - dispatch(saveAppConfig(courseId, appId, values)).then(() => { - history.push(`/course/${courseId}/pages-and-resources`); - }); - }, []); - - if (!appConfig || !app) { - return null; - } - - return ( - - ); -} - -DiscussionConfig.propTypes = { - courseId: PropTypes.string.isRequired, -}; diff --git a/src/pages-and-resources/discussions/Discussions.jsx b/src/pages-and-resources/discussions/Discussions.jsx new file mode 100644 index 000000000..471d62492 --- /dev/null +++ b/src/pages-and-resources/discussions/Discussions.jsx @@ -0,0 +1,150 @@ +import React, { + useCallback, useEffect, useRef, useState, +} from 'react'; +import PropTypes from 'prop-types'; +import { useDispatch } from 'react-redux'; +import { + Switch, + useLocation, + useRouteMatch, +} from 'react-router'; +import { history } from '@edx/frontend-platform'; +import { PageRoute } from '@edx/frontend-platform/react'; +import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; +import { Button, StatefulButton } from '@edx/paragon'; +import { Check } from '@edx/paragon/icons'; + +import FullScreenModal from '../../generic/full-screen-modal/FullScreenModal'; +import Stepper from '../../generic/stepper/Stepper'; + +import messages from './messages'; +import AppList from './AppList'; +import ConfigFormContainer from './ConfigFormContainer'; +import { fetchApps, saveAppConfig } from './data/thunks'; +import StepperFooter from '../../generic/stepper/StepperFooter'; +import StepperHeader from '../../generic/stepper/StepperHeader'; +import StepperBody from '../../generic/stepper/StepperBody'; +import FullScreenModalHeader from '../../generic/full-screen-modal/FullScreenModalHeader'; +import FullScreenModalBody from '../../generic/full-screen-modal/FullScreenModalBody'; + +function Discussions({ courseId, intl }) { + const discussionsPath = `/course/${courseId}/pages-and-resources/discussions`; + + const [selectedAppId, setSelectedAppId] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); + const formRef = useRef(); + + const { path } = useRouteMatch(); + const { pathname } = useLocation(); + const dispatch = useDispatch(); + + useEffect(() => { + dispatch(fetchApps(courseId)); + }, [courseId]); + + const onSelectApp = useCallback((appId) => { + if (selectedAppId === appId) { + setSelectedAppId(null); + } else { + setSelectedAppId(appId); + } + }, [selectedAppId]); + + const onClose = useCallback(() => { + history.push(`/course/${courseId}/pages-and-resources`); + }, [courseId]); + + const onStartConfig = useCallback(() => { + history.push(`${discussionsPath}/configure/${selectedAppId}`); + }, [discussionsPath, selectedAppId]); + + // This causes the form to be submitted from a button outside the form. + const onApply = () => { + setIsSubmitting(true); + formRef.current.requestSubmit(); + }; + + // This is a callback that gets called after the form has been submitted successfully. + const onSubmit = useCallback((values) => { + dispatch(saveAppConfig(courseId, selectedAppId, values)).then(() => { + history.push(`/course/${courseId}/pages-and-resources`); + }); + }, [courseId, selectedAppId, courseId]); + + const onBack = useCallback(() => { + history.push(discussionsPath); + setSelectedAppId(null); + }, [discussionsPath]); + + const isFirstStep = pathname === discussionsPath; + + const steps = [{ + label: 'Select discussion tool', + iconLabel: isFirstStep ? undefined : ( + + ), + }, + { + label: 'Configure discussions', + }]; + + const submitButtonState = isSubmitting ? 'pending' : 'default'; + + return ( + + + + + + + + + + + + + + + + + + {isFirstStep && ( + + )} + {!isFirstStep && ( + + )} + + + + + ); +} + +Discussions.propTypes = { + courseId: PropTypes.string.isRequired, + intl: intlShape.isRequired, +}; + +export default injectIntl(Discussions); diff --git a/src/pages-and-resources/discussions/DiscussionsRoutes.jsx b/src/pages-and-resources/discussions/DiscussionsRoutes.jsx deleted file mode 100644 index 23c700c07..000000000 --- a/src/pages-and-resources/discussions/DiscussionsRoutes.jsx +++ /dev/null @@ -1,40 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import { Switch, useRouteMatch } from 'react-router'; -import { PageRoute } from '@edx/frontend-platform/react'; - -import DiscussionAppList from './DiscussionAppList'; -import DiscussionConfig from './DiscussionConfig'; - -/** - * 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 DiscussionsRoutes({ courseId }) { - const { path } = useRouteMatch(); - return ( - - - - - - - - - ); -} - -DiscussionsRoutes.propTypes = { - courseId: PropTypes.string.isRequired, -}; diff --git a/src/pages-and-resources/discussions/DiscussionsConfigForm.jsx b/src/pages-and-resources/discussions/LtiConfigForm.jsx similarity index 71% rename from src/pages-and-resources/discussions/DiscussionsConfigForm.jsx rename to src/pages-and-resources/discussions/LtiConfigForm.jsx index 6931de6f3..bc7eec278 100644 --- a/src/pages-and-resources/discussions/DiscussionsConfigForm.jsx +++ b/src/pages-and-resources/discussions/LtiConfigForm.jsx @@ -2,16 +2,15 @@ import React from 'react'; import PropTypes from 'prop-types'; import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import { - StatefulButton, Form, Button, Hyperlink, + Form, Hyperlink, } from '@edx/paragon'; import { useFormik } from 'formik'; import * as Yup from 'yup'; -import { history } from '@edx/frontend-platform'; import messages from './messages'; -function DiscussionConfigForm({ - courseId, appConfig, app, submitHandler, intl, +function LtiConfigForm({ + appConfig, app, onSubmit, intl, formRef, }) { const { handleSubmit, @@ -19,7 +18,6 @@ function DiscussionConfigForm({ handleBlur, values, errors, - isSubmitting, } = useFormik({ initialValues: appConfig, validationSchema: Yup.object().shape({ @@ -27,23 +25,25 @@ function DiscussionConfigForm({ consumerSecret: Yup.string().required(intl.formatMessage(messages.consumerSecretRequired)), launchUrl: Yup.string().required(intl.formatMessage(messages.launchUrlRequired)), }), - onSubmit: submitHandler, + onSubmit, }); - const submitButtonState = isSubmitting ? 'pending' : 'default'; - return ( -
+

{intl.formatMessage(messages.configureApp, { name: app.name })}

- {intl.formatMessage(messages.documentationPage)} + + {intl.formatMessage(messages.documentationPage, { name: app.name })} ), name: app.name, @@ -89,22 +89,11 @@ function DiscussionConfigForm({ {intl.formatMessage(messages.launchUrlRequired)} - - ); } -DiscussionConfigForm.propTypes = { +LtiConfigForm.propTypes = { app: PropTypes.shape({ id: PropTypes.string.isRequired, name: PropTypes.string.isRequired, @@ -115,9 +104,10 @@ DiscussionConfigForm.propTypes = { consumerSecret: PropTypes.string.isRequired, launchUrl: PropTypes.string.isRequired, }).isRequired, - courseId: PropTypes.string.isRequired, intl: intlShape.isRequired, - submitHandler: PropTypes.func.isRequired, + onSubmit: PropTypes.func.isRequired, + // eslint-disable-next-line react/forbid-prop-types + formRef: PropTypes.object.isRequired, }; -export default injectIntl(DiscussionConfigForm); +export default injectIntl(LtiConfigForm); diff --git a/src/pages-and-resources/discussions/index.js b/src/pages-and-resources/discussions/index.js new file mode 100644 index 000000000..ba2706fef --- /dev/null +++ b/src/pages-and-resources/discussions/index.js @@ -0,0 +1 @@ +export { default } from './Discussions'; diff --git a/src/pages-and-resources/discussions/messages.js b/src/pages-and-resources/discussions/messages.js index 7cb7058f2..e01e66e7b 100644 --- a/src/pages-and-resources/discussions/messages.js +++ b/src/pages-and-resources/discussions/messages.js @@ -10,9 +10,13 @@ const messages = defineMessages({ defaultMessage: 'Supported Features', }, configureApp: { - id: 'authoring.discussions.configureApp', + id: 'authoring.discussions.configure.app', defaultMessage: 'Configure {name}', }, + configure: { + id: 'authoring.discussions.configure', + defaultMessage: 'Configure Discussions', + }, appLogo: { id: 'authoring.discussions.appLogo', defaultMessage: '{name} Logo', @@ -38,7 +42,7 @@ const messages = defineMessages({ }, documentationPage: { id: 'authoring.discussions.documentationPage', - defaultMessage: 'documentation page', + defaultMessage: 'Visit the {name} documentation page', }, consumerKey: { id: 'authoring.discussions.consumerKey', @@ -73,7 +77,17 @@ const messages = defineMessages({ backButton: { id: 'authoring.discussions.backButton', defaultMessage: 'Back', - description: 'Back button allowing the user to return to discussion app selection.', + description: 'Button allowing the user to return to discussion app selection.', + }, + nextButton: { + id: 'authoring.discussions.nexyButton', + defaultMessage: 'Next', + description: 'Button allowing the user to advance to the second step of discussion configuration.', + }, + applyButton: { + id: 'authoring.discussions.applyButton', + defaultMessage: 'Apply', + description: 'Button allowing the user to submit their discussion configuration.', }, }); diff --git a/src/pages-and-resources/index.scss b/src/pages-and-resources/index.scss index 483b4c2e0..a9680e4cc 100644 --- a/src/pages-and-resources/index.scss +++ b/src/pages-and-resources/index.scss @@ -1 +1 @@ -@import "./discussions/DiscussionAppList"; +@import "./discussions/AppList"; diff --git a/src/store.js b/src/store.js index c489d5643..971d5ca9c 100644 --- a/src/store.js +++ b/src/store.js @@ -3,12 +3,14 @@ import { configureStore } from '@reduxjs/toolkit'; import { reducer as modelsReducer } from './generic/model-store'; import { reducer as courseDetailReducer } from './data/slice'; import { reducer as discussionsReducer } from './pages-and-resources/discussions/data/slice'; +import { reducer as pagesAndResourcesReducer } from './pages-and-resources/data/slice'; export default function initializeStore() { return configureStore({ reducer: { courseDetail: courseDetailReducer, discussions: discussionsReducer, + pagesAndResources: pagesAndResourcesReducer, models: modelsReducer, }, });