Discussions config UI part 2 - FullScreenModal and Stepper! (#53)

* 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.
This commit is contained in:
David Joy
2021-03-12 10:25:55 -05:00
committed by GitHub
parent 5aaee3c63f
commit 6f762edbd3
30 changed files with 856 additions and 374 deletions

239
package-lock.json generated
View File

@@ -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",

View File

@@ -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",

View File

@@ -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 (
<CourseAuthoringPage courseId={courseId}>
<Switch>
<PageRoute exact path={`${path}/pages-and-resources`}>
<PageRoute path={`${path}/pages-and-resources`}>
<PagesAndResources courseId={courseId} />
</PageRoute>
<PageRoute path={`${path}/pages-and-resources/discussion`}>
<DiscussionsRoutes courseId={courseId} />
</PageRoute>
<PageRoute path={`${path}/proctored-exam-settings`}>
<ProctoredExamSettings courseId={courseId} />
</PageRoute>

View File

@@ -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 (
<ModalLayer isOpen onClose={onClose}>
<div
role="dialog"
aria-label={title}
className={classNames(
'bg-white',
'd-flex',
'flex-column',
'vw-100',
'vh-100',
)}
>
{children}
</div>
</ModalLayer>
);
}
FullScreenModal.propTypes = {
children: PropTypes.node.isRequired,
title: PropTypes.string.isRequired,
onClose: PropTypes.func.isRequired,
};

View File

@@ -0,0 +1,14 @@
import React from 'react';
import PropTypes from 'prop-types';
export default function FullScreenModalBody({ children }) {
return (
<div className="flex-grow-1 overflow-auto">
{children}
</div>
);
}
FullScreenModalBody.propTypes = {
children: PropTypes.node.isRequired,
};

View File

@@ -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 (
<div
className={classNames(
'bg-primary',
'text-white',
'd-flex',
'justify-content-between',
'align-items-center',
className,
)}
>
<h2 className="pl-3 h6 mb-0">{title}</h2>
<ModalCloseButton variant="outline-link" className="text-white">
<Close />
</ModalCloseButton>
</div>
);
}
FullScreenModalHeader.propTypes = {
className: PropTypes.string,
title: PropTypes.string.isRequired,
};
FullScreenModalHeader.defaultProps = {
className: null,
};

View File

@@ -0,0 +1,30 @@
import React from 'react';
import PropTypes from 'prop-types';
export default function StepIcon({ children, size }) {
return (
<div
className="rounded-circle small mr-2 bg-primary text-white d-flex justify-content-center align-items-center"
style={{
// TODO: Is there a better way to lock the shape of this thing?
width: size,
minWidth: size,
height: size,
minHeight: size,
maxWidth: size,
maxHeight: size,
}}
>
{children}
</div>
);
}
StepIcon.propTypes = {
children: PropTypes.node.isRequired,
size: PropTypes.string,
};
StepIcon.defaultProps = {
size: '1.3rem',
};

View File

@@ -0,0 +1,7 @@
import React from 'react';
export default function StepLine() {
return (
<div className="border-bottom mx-2" style={{ width: '5rem' }} />
);
}

View File

@@ -0,0 +1,26 @@
import React from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
export default function Stepper({ children, className }) {
return (
<div
className={classNames(
'd-flex',
'flex-column',
className,
)}
>
{children}
</div>
);
}
Stepper.propTypes = {
className: PropTypes.string,
children: PropTypes.node.isRequired,
};
Stepper.defaultProps = {
className: null,
};

View File

@@ -0,0 +1,25 @@
import React from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
export default function StepperBody({ children, className }) {
return (
<div className={classNames(
'overflow-auto',
'flex-grow-1',
className,
)}
>
{children}
</div>
);
}
StepperBody.propTypes = {
children: PropTypes.node.isRequired,
className: PropTypes.string,
};
StepperBody.defaultProps = {
className: null,
};

View File

@@ -0,0 +1,33 @@
import React from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
export default function StepperFooter({ children, className }) {
return (
<div
className={classNames(
'bg-white',
'p-2',
// position-relative raises this div to a higher 'layer' so that its drop shadow falls
// above the content in the div with our content.
'position-relative',
'w-100',
className,
)}
style={{
boxShadow: '0 -0.25rem 0.5rem rgba(0, 0, 0, 0.3)',
}}
>
{children}
</div>
);
}
StepperFooter.propTypes = {
children: PropTypes.node.isRequired,
className: PropTypes.string,
};
StepperFooter.defaultProps = {
className: null,
};

View File

@@ -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 (
<div
className={classNames(
'p-2',
'd-flex',
'border-bottom',
'border-light',
'justify-content-center',
'align-items-center',
className,
)}
>
{steps.map(({ iconLabel, label }, index) => {
const isNotLastStep = index < steps.length - 1;
return (
// eslint-disable-next-line react/no-array-index-key
<React.Fragment key={`${index}-${label}`}>
<StepIcon>
{iconLabel || index + 1}
</StepIcon>
<span className="font-weight-bold">{label}</span>
{isNotLastStep && (
<StepLine />
)}
</React.Fragment>
);
})}
</div>
);
}
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,
};

View File

@@ -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 (
<main>
<div className="container-fluid bg-info-100 pb-3">
@@ -80,6 +40,11 @@ function PagesAndResources({ courseId, intl }) {
<PageGrid pages={pages} />
<ResourceList />
</div>
<Switch>
<PageRoute path={`${path}/discussions`}>
<Discussions courseId={courseId} />
</PageRoute>
</Switch>
</main>
);
}

View File

@@ -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',
},
],
});
}

View File

@@ -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;

View File

@@ -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 }));
}
};
}

View File

@@ -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 (
<Card
key={app.id}
tabIndex={app.isAvailable ? '-1' : ''}
onClick={() => { 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);

View File

@@ -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 (
<div className="m-5">
<h2 className="my-4 text-center">{intl.formatMessage(messages.heading)}</h2>
<CardGrid
columnSizes={{
xs: 12,
sm: 6,
lg: 4,
}}
>
{apps.map(app => (
<AppCard
key={app.id}
app={app}
selected={app.id === selectedAppId}
onClick={onSelectApp}
/>
))}
</CardGrid>
<h2 className="my-3">
{intl.formatMessage(messages.supportedFeatures)}
</h2>
<FeaturesTable
apps={apps}
features={features}
/>
</div>
);
}
AppList.propTypes = {
intl: intlShape.isRequired,
onSelectApp: PropTypes.func.isRequired,
selectedAppId: PropTypes.string,
};
AppList.defaultProps = {
selectedAppId: null,
};
export default injectIntl(AppList);

View File

@@ -1,4 +1,4 @@
describe('DiscussionAppList', () => {
describe('AppList', () => {
it('will pass because it is an example', () => {
});

View File

@@ -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 (
<LtiConfigForm
formRef={formRef}
app={app}
appConfig={appConfig}
onSubmit={onSubmit}
/>
);
}
ConfigFormContainer.propTypes = {
courseId: PropTypes.string.isRequired,
onSubmit: PropTypes.func.isRequired,
// eslint-disable-next-line react/forbid-prop-types
formRef: PropTypes.object.isRequired,
};

View File

@@ -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 (
<div className="m-5">
<h2 className="my-4 text-center">{intl.formatMessage(messages.heading)}</h2>
<CardGrid
columnSizes={{
xs: 12,
sm: 6,
lg: 4,
}}
>
{apps.map(app => (
<DiscussionAppCard
key={app.id}
app={app}
selected={app.id === selectedAppId}
clickHandler={handleSelectApp}
/>
))}
</CardGrid>
<div className="d-flex justify-content-between align-items-center">
<h2 className="my-3">
{intl.formatMessage(messages.supportedFeatures)}
</h2>
{selectedAppId && (
<Button variant="primary" onClick={handleConfigureApp}>
{intl.formatMessage(messages.configureApp, { name: selectedApp.name })}
</Button>
)}
</div>
<FeaturesTable
apps={apps}
features={features}
/>
</div>
);
}
DiscussionAppList.propTypes = {
courseId: PropTypes.string.isRequired,
intl: intlShape.isRequired,
};
export default injectIntl(DiscussionAppList);

View File

@@ -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 (
<DiscussionsConfigForm
courseId={courseId}
app={app}
appConfig={appConfig}
submitHandler={handleSubmit}
/>
);
}
DiscussionConfig.propTypes = {
courseId: PropTypes.string.isRequired,
};

View File

@@ -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 : (
<Check style={{ width: '1rem', height: '1rem' }} />
),
},
{
label: 'Configure discussions',
}];
const submitButtonState = isSubmitting ? 'pending' : 'default';
return (
<FullScreenModal title={intl.formatMessage(messages.configure)} onClose={onClose}>
<FullScreenModalHeader title={intl.formatMessage(messages.configure)} />
<FullScreenModalBody className="d-flex flex-column">
<Stepper className="h-100">
<StepperHeader steps={steps} />
<StepperBody>
<Switch>
<PageRoute exact path={`${path}`}>
<AppList
onSelectApp={onSelectApp}
selectedAppId={selectedAppId}
/>
</PageRoute>
<PageRoute path={`${path}/configure/:appId`}>
<ConfigFormContainer
courseId={courseId}
selectedAppId={selectedAppId}
onSubmit={onSubmit}
formRef={formRef}
/>
</PageRoute>
</Switch>
</StepperBody>
<StepperFooter className="d-flex justify-content-between align-items-center">
<Button variant="outline-primary" onClick={onBack} disabled={isFirstStep}>
{intl.formatMessage(messages.backButton)}
</Button>
{isFirstStep && (
<Button variant="primary" onClick={onStartConfig} disabled={!selectedAppId}>
{intl.formatMessage(messages.nextButton)}
</Button>
)}
{!isFirstStep && (
<StatefulButton
labels={{
default: intl.formatMessage(messages.applyButton),
pending: intl.formatMessage(messages.savingConfig),
complete: intl.formatMessage(messages.savedConfig),
}}
state={submitButtonState}
className="mr-3"
onClick={onApply}
/>
)}
</StepperFooter>
</Stepper>
</FullScreenModalBody>
</FullScreenModal>
);
}
Discussions.propTypes = {
courseId: PropTypes.string.isRequired,
intl: intlShape.isRequired,
};
export default injectIntl(Discussions);

View File

@@ -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 (
<Switch>
<PageRoute exact path={`${path}`}>
<DiscussionAppList courseId={courseId} />
</PageRoute>
<PageRoute path={`${path}/configure/:appId`}>
<DiscussionConfig courseId={courseId} />
</PageRoute>
</Switch>
);
}
DiscussionsRoutes.propTypes = {
courseId: PropTypes.string.isRequired,
};

View File

@@ -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 (
<Form className="m-5" onSubmit={handleSubmit}>
<Form ref={formRef} className="m-5" onSubmit={handleSubmit}>
<h1>{intl.formatMessage(messages.configureApp, { name: app.name })}</h1>
<p>
<FormattedMessage
id="authoring.discussions.appDocInstructions"
defaultMessage="Please visit the {documentationPageLink} for {name} to set up the tool, then paste your consumer key and consumer secret here."
description="Instructions for the user to go visit a third party app's documentation to learn how to generate a set of values needed in this form."
defaultMessage="{documentationPageLink} to set up the tool, then paste your consumer key and consumer secret below:"
description="Instructions for the user to go visit a third party app's documentation to learn how to generate a set of values needed in this form. documentationPageLink says 'Visit the {name} documentation page'"
values={{
documentationPageLink: (
<Hyperlink destination={app.documentationUrl}>
{intl.formatMessage(messages.documentationPage)}
<Hyperlink
destination={app.documentationUrl}
target="_blank"
rel="noopener noreferrer"
>
{intl.formatMessage(messages.documentationPage, { name: app.name })}
</Hyperlink>
),
name: app.name,
@@ -89,22 +89,11 @@ function DiscussionConfigForm({
{intl.formatMessage(messages.launchUrlRequired)}
</Form.Control.Feedback>
</Form.Group>
<StatefulButton
labels={{
default: intl.formatMessage(messages.saveConfig),
pending: intl.formatMessage(messages.savingConfig),
complete: intl.formatMessage(messages.savedConfig),
}}
type="submit"
state={submitButtonState}
className="mr-3"
/>
<Button variant="link" onClick={() => history.push(`/course/${courseId}/pages-and-resources/discussion`)}>{intl.formatMessage(messages.backButton)}</Button>
</Form>
);
}
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);

View File

@@ -0,0 +1 @@
export { default } from './Discussions';

View File

@@ -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.',
},
});

View File

@@ -1 +1 @@
@import "./discussions/DiscussionAppList";
@import "./discussions/AppList";

View File

@@ -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,
},
});