Compare commits
11 Commits
mikix/expi
...
djoy/id_re
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d997431382 | ||
|
|
aca3519a7d | ||
|
|
1b38b02b9c | ||
|
|
8967edee8d | ||
|
|
2c9fcce01e | ||
|
|
9a315aa29d | ||
|
|
ad74b2295b | ||
|
|
c8961d3777 | ||
|
|
c7c401e385 | ||
|
|
9e0f192ae7 | ||
|
|
4667535c0c |
@@ -4,6 +4,6 @@ Because we have a variety of models in this app (course, section, sequence, unit
|
||||
|
||||
https://redux.js.org/faq/organizing-state#how-do-i-organize-nested-or-duplicate-data-in-my-state
|
||||
|
||||
Different modules of the application maintain individual/lists of IDs that reference data stored in the model store. These are akin to indices in a database, in that they allow you to quickly extract data from the model store without iteration or filtering.
|
||||
Different modules of the application maintain individual/lists of IDs that reference data stored in the model store. These are akin to indices in a database, in that they allow you to quickly extract data from the model store without iteration or filtering.
|
||||
|
||||
A common pattern when loading data from an API endpoint is to use the model-store's redux actions (addModel, updateModel, etc.) to load the "models" themselves into the model store by ID, and then dispatch another action to save references elsewhere in the redux store to the data that was just added. When adding courses, sequences, etc., to model-store, we also save the courseId and sequenceId in the 'courseware' part of redux. This means the courseware React Components can extract the data from the model-store quickly by using the courseId as a key: `state.models.courses[state.courseware.courseId]`. For an array, it iterates once over the ID list in order to extract the models from model-store. This iteration is done when React components' re-render, and can be done less often through memoization as necessary.
|
||||
A common pattern when loading data from an API endpoint is to use the model-store's redux actions (addModel, updateModel, etc.) to load the "models" themselves into the model store by ID, and then dispatch another action to save references elsewhere in the redux store to the data that was just added. When adding courses, sequences, etc., to model-store, we also save the courseId and sequenceId in the 'courseware' part of redux. This means the courseware React Components can extract the data from the model-store quickly by using the courseId as a key: `state.models.courses[state.activeCourse.courseId]`. For an array, it iterates once over the ID list in order to extract the models from model-store. This iteration is done when React components' re-render, and can be done less often through memoization as necessary.
|
||||
|
||||
205
package-lock.json
generated
205
package-lock.json
generated
@@ -1408,9 +1408,9 @@
|
||||
}
|
||||
},
|
||||
"@edx/paragon": {
|
||||
"version": "7.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@edx/paragon/-/paragon-7.2.1.tgz",
|
||||
"integrity": "sha512-5TUrMj4Wry0PAFF/uZp8xWBzNOCc6UB4W04NqjmTlJyPRI0fZgKc7+aIQeI6jIHR8GsjTUwUzEMgZ2+aMyCu4A==",
|
||||
"version": "9.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@edx/paragon/-/paragon-9.1.1.tgz",
|
||||
"integrity": "sha512-kSnALJeIBEtGb7GiEq1pOihQlNesLFwYoHovxAatyteZYcjOF1I27W3VJdx1mE07ZjfUUwrzY2czvvPXMWKP8A==",
|
||||
"requires": {
|
||||
"@fortawesome/fontawesome-svg-core": "^1.2.21",
|
||||
"@fortawesome/free-solid-svg-icons": "^5.10.1",
|
||||
@@ -1422,6 +1422,7 @@
|
||||
"font-awesome": "^4.7.0",
|
||||
"mailto-link": "^1.0.0",
|
||||
"prop-types": "^15.7.2",
|
||||
"react-bootstrap": "^1.2.2",
|
||||
"react-proptype-conditional-require": "^1.0.4",
|
||||
"react-responsive": "^6.1.1",
|
||||
"react-transition-group": "^4.0.0",
|
||||
@@ -2416,6 +2417,11 @@
|
||||
"fastq": "^1.6.0"
|
||||
}
|
||||
},
|
||||
"@popperjs/core": {
|
||||
"version": "2.4.4",
|
||||
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.4.4.tgz",
|
||||
"integrity": "sha512-1oO6+dN5kdIA3sKPZhRGJTfGVP4SWV6KqlMOwry4J3HfyD68sl/3KmG7DeYUzvN+RbhXDnv/D8vNNB8168tAMg=="
|
||||
},
|
||||
"@reduxjs/toolkit": {
|
||||
"version": "1.3.6",
|
||||
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-1.3.6.tgz",
|
||||
@@ -2427,6 +2433,20 @@
|
||||
"reselect": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"@restart/context": {
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@restart/context/-/context-2.1.4.tgz",
|
||||
"integrity": "sha512-INJYZQJP7g+IoDUh/475NlGiTeMfwTXUEr3tmRneckHIxNolGOW9CTq83S8cxq0CgJwwcMzMJFchxvlwe7Rk8Q=="
|
||||
},
|
||||
"@restart/hooks": {
|
||||
"version": "0.3.25",
|
||||
"resolved": "https://registry.npmjs.org/@restart/hooks/-/hooks-0.3.25.tgz",
|
||||
"integrity": "sha512-m2v3N5pxTsIiSH74/sb1yW8D9RxkJidGW+5Mfwn/lHb2QzhZNlaU1su7abSyT9EGf0xS/0waLjrf7/XxQHUk7w==",
|
||||
"requires": {
|
||||
"lodash": "^4.17.15",
|
||||
"lodash-es": "^4.17.15"
|
||||
}
|
||||
},
|
||||
"@sindresorhus/is": {
|
||||
"version": "0.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-0.7.0.tgz",
|
||||
@@ -2899,6 +2919,11 @@
|
||||
"@babel/types": "^7.3.0"
|
||||
}
|
||||
},
|
||||
"@types/classnames": {
|
||||
"version": "2.2.10",
|
||||
"resolved": "https://registry.npmjs.org/@types/classnames/-/classnames-2.2.10.tgz",
|
||||
"integrity": "sha512-1UzDldn9GfYYEsWWnn/P4wkTlkZDH7lDb0wBMGbtIQc9zXEQq7FlKBdZUn6OBqD8sKZZ2RQO2mAjGpXiDGoRmQ=="
|
||||
},
|
||||
"@types/color-name": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/color-name/-/color-name-1.1.1.tgz",
|
||||
@@ -2935,6 +2960,11 @@
|
||||
"integrity": "sha512-iYCgjm1dGPRuo12+BStjd1HiVQqhlRhWDOQigNxn023HcjnhsiFz9pc6CzJj4HwDCSQca9bxTL4PxJDbkdm3PA==",
|
||||
"dev": true
|
||||
},
|
||||
"@types/invariant": {
|
||||
"version": "2.2.33",
|
||||
"resolved": "https://registry.npmjs.org/@types/invariant/-/invariant-2.2.33.tgz",
|
||||
"integrity": "sha512-/jUNmS8d4bCKdqslfxW6dg/9Gksfzxz67IYfqApHn+HvHlMVXwYv2zpTDnS/yaK9BB0i0GlBTaYci0EFE62Hmw=="
|
||||
},
|
||||
"@types/istanbul-lib-coverage": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.3.tgz",
|
||||
@@ -3111,12 +3141,41 @@
|
||||
"integrity": "sha512-boy4xPNEtiw6N3abRhBi/e7hNvy3Tt8E9ZRAQrwAGzoCGZS/1wjo9KY7JHhnfnEsG5wSjDbymCozUM9a3ea7OQ==",
|
||||
"dev": true
|
||||
},
|
||||
"@types/prop-types": {
|
||||
"version": "15.7.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.3.tgz",
|
||||
"integrity": "sha512-KfRL3PuHmqQLOG+2tGpRO26Ctg+Cq1E01D2DMriKEATHgWLfeNDmq9e29Q9WIky0dQ3NPkd1mzYH8Lm936Z9qw=="
|
||||
},
|
||||
"@types/q": {
|
||||
"version": "1.5.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/q/-/q-1.5.4.tgz",
|
||||
"integrity": "sha512-1HcDas8SEj4z1Wc696tH56G8OlRaH/sqZOynNNB+HF0WOeXPaxTtbYzJY2oEfiUxjSKjhCKr+MvR7dCHcEelug==",
|
||||
"dev": true
|
||||
},
|
||||
"@types/react": {
|
||||
"version": "16.9.44",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-16.9.44.tgz",
|
||||
"integrity": "sha512-BtLoJrXdW8DVZauKP+bY4Kmiq7ubcJq+H/aCpRfvPF7RAT3RwR73Sg8szdc2YasbAlWBDrQ6Q+AFM0KwtQY+WQ==",
|
||||
"requires": {
|
||||
"@types/prop-types": "*",
|
||||
"csstype": "^3.0.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"csstype": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.0.2.tgz",
|
||||
"integrity": "sha512-ofovWglpqoqbfLNOTBNZLSbMuGrblAf1efvvArGKOZMBrIoJeu5UsAipQolkijtyQx5MtAzT/J9IHj/CEY1mJw=="
|
||||
}
|
||||
}
|
||||
},
|
||||
"@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==",
|
||||
"requires": {
|
||||
"@types/react": "*"
|
||||
}
|
||||
},
|
||||
"@types/schema-utils": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/schema-utils/-/schema-utils-1.0.0.tgz",
|
||||
@@ -3159,6 +3218,11 @@
|
||||
"source-map": "^0.6.1"
|
||||
}
|
||||
},
|
||||
"@types/warning": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/warning/-/warning-3.0.0.tgz",
|
||||
"integrity": "sha1-DSUBJorY+ZYrdA04fEZU9fjiPlI="
|
||||
},
|
||||
"@types/webpack": {
|
||||
"version": "4.41.17",
|
||||
"resolved": "https://registry.npmjs.org/@types/webpack/-/webpack-4.41.17.tgz",
|
||||
@@ -3524,20 +3588,19 @@
|
||||
}
|
||||
},
|
||||
"airbnb-prop-types": {
|
||||
"version": "2.15.0",
|
||||
"resolved": "https://registry.npmjs.org/airbnb-prop-types/-/airbnb-prop-types-2.15.0.tgz",
|
||||
"integrity": "sha512-jUh2/hfKsRjNFC4XONQrxo/n/3GG4Tn6Hl0WlFQN5PY9OMC9loSCoAYKnZsWaP8wEfd5xcrPloK0Zg6iS1xwVA==",
|
||||
"version": "2.16.0",
|
||||
"resolved": "https://registry.npmjs.org/airbnb-prop-types/-/airbnb-prop-types-2.16.0.tgz",
|
||||
"integrity": "sha512-7WHOFolP/6cS96PhKNrslCLMYAI8yB1Pp6u6XmxozQOiZbsI5ycglZr5cHhBFfuRcQQjzCMith5ZPZdYiJCxUg==",
|
||||
"requires": {
|
||||
"array.prototype.find": "^2.1.0",
|
||||
"function.prototype.name": "^1.1.1",
|
||||
"has": "^1.0.3",
|
||||
"is-regex": "^1.0.4",
|
||||
"object-is": "^1.0.1",
|
||||
"array.prototype.find": "^2.1.1",
|
||||
"function.prototype.name": "^1.1.2",
|
||||
"is-regex": "^1.1.0",
|
||||
"object-is": "^1.1.2",
|
||||
"object.assign": "^4.1.0",
|
||||
"object.entries": "^1.1.0",
|
||||
"object.entries": "^1.1.2",
|
||||
"prop-types": "^15.7.2",
|
||||
"prop-types-exact": "^1.2.0",
|
||||
"react-is": "^16.9.0"
|
||||
"react-is": "^16.13.1"
|
||||
}
|
||||
},
|
||||
"ajv": {
|
||||
@@ -14037,6 +14100,11 @@
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.15.tgz",
|
||||
"integrity": "sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A=="
|
||||
},
|
||||
"lodash-es": {
|
||||
"version": "4.17.15",
|
||||
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.15.tgz",
|
||||
"integrity": "sha512-rlrc3yU3+JNOpZ9zj5pQtxnx2THmvRykwL4Xlxoa8I9lHBlVbbyPhgyPMioxVZ4NqyxaVVtaJnzsyOidQIhyyQ=="
|
||||
},
|
||||
"lodash.camelcase": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz",
|
||||
@@ -15762,6 +15830,11 @@
|
||||
"integrity": "sha1-bVuTSkVpk7I9N/QKOC1vFmao5cY=",
|
||||
"dev": true
|
||||
},
|
||||
"parse-srcset": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/parse-srcset/-/parse-srcset-1.0.2.tgz",
|
||||
"integrity": "sha1-8r0iH2zJcKk42IVWq8WJyqqiveE="
|
||||
},
|
||||
"parse5": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/parse5/-/parse5-5.1.1.tgz",
|
||||
@@ -16841,6 +16914,15 @@
|
||||
"reflect.ownkeys": "^0.2.0"
|
||||
}
|
||||
},
|
||||
"prop-types-extra": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/prop-types-extra/-/prop-types-extra-1.1.1.tgz",
|
||||
"integrity": "sha512-59+AHNnHYCdiC+vMwY52WmvP5dM3QLeoumYuEyceQDi9aEhtwN9zIQ2ZNo25sMyXnbh32h+P1ezDsUpUH3JAew==",
|
||||
"requires": {
|
||||
"react-is": "^16.3.2",
|
||||
"warning": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"proto-list": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz",
|
||||
@@ -17027,6 +17109,44 @@
|
||||
"prop-types": "^15.6.2"
|
||||
}
|
||||
},
|
||||
"react-bootstrap": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/react-bootstrap/-/react-bootstrap-1.3.0.tgz",
|
||||
"integrity": "sha512-GYj0c6FO9mx7DaO8Xyz2zs0IcQ6CGCtM3O6/feIoCaG4N8B0+l4eqL7stlMcLpqO4d8NG2PoMO/AbUOD+MO7mg==",
|
||||
"requires": {
|
||||
"@babel/runtime": "^7.4.2",
|
||||
"@restart/context": "^2.1.4",
|
||||
"@restart/hooks": "^0.3.21",
|
||||
"@types/classnames": "^2.2.10",
|
||||
"@types/invariant": "^2.2.33",
|
||||
"@types/prop-types": "^15.7.3",
|
||||
"@types/react": "^16.9.35",
|
||||
"@types/react-transition-group": "^4.4.0",
|
||||
"@types/warning": "^3.0.0",
|
||||
"classnames": "^2.2.6",
|
||||
"dom-helpers": "^5.1.2",
|
||||
"invariant": "^2.2.4",
|
||||
"prop-types": "^15.7.2",
|
||||
"prop-types-extra": "^1.1.0",
|
||||
"react-overlays": "^4.1.0",
|
||||
"react-transition-group": "^4.4.1",
|
||||
"uncontrollable": "^7.0.0",
|
||||
"warning": "^4.0.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"react-transition-group": {
|
||||
"version": "4.4.1",
|
||||
"resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.1.tgz",
|
||||
"integrity": "sha512-Djqr7OQ2aPUiYurhPalTrVy9ddmFCCzwhqQmtN+J3+3DzLO209Fdr70QrN8Z3DsglWql6iY1lDWAfpFiBtuKGw==",
|
||||
"requires": {
|
||||
"@babel/runtime": "^7.5.5",
|
||||
"dom-helpers": "^5.0.1",
|
||||
"loose-envify": "^1.4.0",
|
||||
"prop-types": "^15.6.2"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"react-break": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/react-break/-/react-break-1.3.2.tgz",
|
||||
@@ -17392,6 +17512,26 @@
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="
|
||||
},
|
||||
"react-lifecycles-compat": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz",
|
||||
"integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA=="
|
||||
},
|
||||
"react-overlays": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/react-overlays/-/react-overlays-4.1.0.tgz",
|
||||
"integrity": "sha512-vdRpnKe0ckWOOD9uWdqykLUPHLPndIiUV7XfEKsi5008xiyHCfL8bxsx4LbMrfnxW1LzRthLyfy50XYRFNQqqw==",
|
||||
"requires": {
|
||||
"@babel/runtime": "^7.4.5",
|
||||
"@popperjs/core": "^2.0.0",
|
||||
"@restart/hooks": "^0.3.12",
|
||||
"@types/warning": "^3.0.0",
|
||||
"dom-helpers": "^5.1.0",
|
||||
"prop-types": "^15.7.2",
|
||||
"uncontrollable": "^7.0.0",
|
||||
"warning": "^4.0.3"
|
||||
}
|
||||
},
|
||||
"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",
|
||||
@@ -18316,16 +18456,14 @@
|
||||
}
|
||||
},
|
||||
"sanitize-html": {
|
||||
"version": "1.27.0",
|
||||
"resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-1.27.0.tgz",
|
||||
"integrity": "sha512-U1btucGeYVpg0GoK43jPpe/bDCV4cBOGuxzv5NBd0bOjyZdMKY0n98S/vNlO1wVwre0VCj8H3hbzE7gD2+RjKA==",
|
||||
"version": "1.27.2",
|
||||
"resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-1.27.2.tgz",
|
||||
"integrity": "sha512-REZETvhFFChM3zyQS8XoR02j5U56HtyQkxsc8cb5HEi3XU0AAX9TuKvWe3ESR0F0IA81ZghA+5YpJg8C35AFyQ==",
|
||||
"requires": {
|
||||
"chalk": "^2.4.1",
|
||||
"htmlparser2": "^4.1.0",
|
||||
"lodash": "^4.17.15",
|
||||
"postcss": "^7.0.27",
|
||||
"srcset": "^2.0.1",
|
||||
"xtend": "^4.0.1"
|
||||
"parse-srcset": "^1.0.2",
|
||||
"postcss": "^7.0.27"
|
||||
}
|
||||
},
|
||||
"sass-graph": {
|
||||
@@ -19505,11 +19643,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"srcset": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/srcset/-/srcset-2.0.1.tgz",
|
||||
"integrity": "sha512-00kZI87TdRKwt+P8jj8UZxbfp7mK2ufxcIMWvhAOZNJTRROimpHeruWrGvCZneiuVDLqdyHefVp748ECTnyUBQ=="
|
||||
},
|
||||
"sshpk": {
|
||||
"version": "1.16.1",
|
||||
"resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.16.1.tgz",
|
||||
@@ -20648,6 +20781,17 @@
|
||||
"through": "^2.3.8"
|
||||
}
|
||||
},
|
||||
"uncontrollable": {
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/uncontrollable/-/uncontrollable-7.1.1.tgz",
|
||||
"integrity": "sha512-EcPYhot3uWTS3w00R32R2+vS8Vr53tttrvMj/yA1uYRhf8hbTG2GyugGqWDY0qIskxn0uTTojVd6wPYW9ZEf8Q==",
|
||||
"requires": {
|
||||
"@babel/runtime": "^7.6.3",
|
||||
"@types/react": "^16.9.11",
|
||||
"invariant": "^2.2.4",
|
||||
"react-lifecycles-compat": "^3.0.4"
|
||||
}
|
||||
},
|
||||
"unicode-canonical-property-names-ecmascript": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-1.0.4.tgz",
|
||||
@@ -21031,6 +21175,14 @@
|
||||
"makeerror": "1.0.x"
|
||||
}
|
||||
},
|
||||
"warning": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz",
|
||||
"integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==",
|
||||
"requires": {
|
||||
"loose-envify": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"watchpack": {
|
||||
"version": "1.7.2",
|
||||
"resolved": "https://registry.npmjs.org/watchpack/-/watchpack-1.7.2.tgz",
|
||||
@@ -22206,7 +22358,8 @@
|
||||
"xtend": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
||||
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="
|
||||
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
|
||||
"dev": true
|
||||
},
|
||||
"y18n": {
|
||||
"version": "4.0.0",
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
"@edx/frontend-component-footer": "10.0.11",
|
||||
"@edx/frontend-component-header": "2.0.5",
|
||||
"@edx/frontend-platform": "1.5.2",
|
||||
"@edx/paragon": "7.2.1",
|
||||
"@edx/paragon": "^9.1.1",
|
||||
"@fortawesome/fontawesome-svg-core": "1.2.30",
|
||||
"@fortawesome/free-brands-svg-icons": "5.13.1",
|
||||
"@fortawesome/free-regular-svg-icons": "5.13.1",
|
||||
|
||||
8
src/active-course/data/selectors.js
Normal file
8
src/active-course/data/selectors.js
Normal file
@@ -0,0 +1,8 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
import { createSelector } from 'reselect';
|
||||
|
||||
export const activeCourseSelector = createSelector(
|
||||
(state) => state.models.courses || {},
|
||||
(state) => state.activeCourse.courseId,
|
||||
(coursesById, courseId) => (coursesById[courseId] ? coursesById[courseId] : null),
|
||||
);
|
||||
44
src/active-course/data/slice.js
Normal file
44
src/active-course/data/slice.js
Normal file
@@ -0,0 +1,44 @@
|
||||
/* eslint-disable no-param-reassign */
|
||||
import { createSlice } from '@reduxjs/toolkit';
|
||||
|
||||
export const COURSE_LOADING = 'loading';
|
||||
export const COURSE_LOADED = 'loaded';
|
||||
export const COURSE_FAILED = 'failed';
|
||||
export const COURSE_DENIED = 'denied';
|
||||
|
||||
const slice = createSlice({
|
||||
name: 'activeCourse',
|
||||
initialState: {
|
||||
courseStatus: COURSE_LOADING,
|
||||
courseId: null,
|
||||
},
|
||||
reducers: {
|
||||
fetchCourseRequest: (state, { payload }) => {
|
||||
state.courseId = payload.courseId;
|
||||
state.courseStatus = COURSE_LOADING;
|
||||
},
|
||||
fetchCourseSuccess: (state, { payload }) => {
|
||||
state.courseId = payload.courseId;
|
||||
state.courseStatus = COURSE_LOADED;
|
||||
},
|
||||
fetchCourseFailure: (state, { payload }) => {
|
||||
state.courseId = payload.courseId;
|
||||
state.courseStatus = COURSE_FAILED;
|
||||
},
|
||||
fetchCourseDenied: (state, { payload }) => {
|
||||
state.courseId = payload.courseId;
|
||||
state.courseStatus = COURSE_DENIED;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const {
|
||||
fetchCourseRequest,
|
||||
fetchCourseSuccess,
|
||||
fetchCourseFailure,
|
||||
fetchCourseDenied,
|
||||
} = slice.actions;
|
||||
|
||||
export const {
|
||||
reducer,
|
||||
} = slice;
|
||||
12
src/active-course/index.js
Normal file
12
src/active-course/index.js
Normal file
@@ -0,0 +1,12 @@
|
||||
export { activeCourseSelector } from './data/selectors';
|
||||
export {
|
||||
reducer,
|
||||
COURSE_LOADING,
|
||||
COURSE_LOADED,
|
||||
COURSE_FAILED,
|
||||
COURSE_DENIED,
|
||||
fetchCourseRequest,
|
||||
fetchCourseSuccess,
|
||||
fetchCourseFailure,
|
||||
fetchCourseDenied,
|
||||
} from './data/slice';
|
||||
@@ -1,14 +1,15 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { Alert } from '../../generic/user-messages';
|
||||
import { Alert, ALERT_TYPES } from '../../generic/user-messages';
|
||||
|
||||
function AccessExpirationAlert({ payload }) {
|
||||
const {
|
||||
rawHtml,
|
||||
} = payload;
|
||||
return rawHtml && (
|
||||
<Alert type="info">
|
||||
<Alert type={ALERT_TYPES.INFO}>
|
||||
{/* eslint-disable-next-line react/no-danger */}
|
||||
<div dangerouslySetInnerHTML={{ __html: rawHtml }} />
|
||||
</Alert>
|
||||
);
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
import { useMemo } from 'react';
|
||||
import { useModel } from '../../generic/model-store';
|
||||
import React, { useMemo } from 'react';
|
||||
import { useAlert } from '../../generic/user-messages';
|
||||
|
||||
export function useAccessExpirationAlert(courseId) {
|
||||
const course = useModel('courses', courseId);
|
||||
const rawHtml = (course && course.courseExpiredMessage) || null;
|
||||
const AccessExpirationAlert = React.lazy(() => import('./AccessExpirationAlert'));
|
||||
|
||||
function useAccessExpirationAlert(courseExpiredMessage, topic) {
|
||||
const rawHtml = courseExpiredMessage || null;
|
||||
const isVisible = !!rawHtml; // If it exists, show it.
|
||||
|
||||
const payload = useMemo(() => ({ rawHtml }), [rawHtml]);
|
||||
|
||||
useAlert(isVisible, {
|
||||
code: 'clientAccessExpirationAlert',
|
||||
topic: 'course',
|
||||
payload,
|
||||
topic,
|
||||
});
|
||||
|
||||
return { clientAccessExpirationAlert: AccessExpirationAlert };
|
||||
}
|
||||
|
||||
export default useAccessExpirationAlert;
|
||||
|
||||
@@ -1,2 +1 @@
|
||||
export { default as AccessExpirationAlert } from './AccessExpirationAlert';
|
||||
export { useAccessExpirationAlert } from './hooks';
|
||||
export { default } from './hooks';
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { Alert } from '../../generic/user-messages';
|
||||
import { Alert, ALERT_TYPES } from '../../generic/user-messages';
|
||||
|
||||
function OfferAlert({ payload }) {
|
||||
const {
|
||||
rawHtml,
|
||||
} = payload;
|
||||
return rawHtml && (
|
||||
<Alert type="info">
|
||||
<Alert type={ALERT_TYPES.INFO}>
|
||||
{/* eslint-disable-next-line react/no-danger */}
|
||||
<div dangerouslySetInnerHTML={{ __html: rawHtml }} />
|
||||
</Alert>
|
||||
);
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
import { useModel } from '../../generic/model-store';
|
||||
import React from 'react';
|
||||
import { useAlert } from '../../generic/user-messages';
|
||||
|
||||
export function useOfferAlert(courseId) {
|
||||
const course = useModel('courses', courseId);
|
||||
const rawHtml = (course && course.offerHtml) || null;
|
||||
const OfferAlert = React.lazy(() => import('./OfferAlert'));
|
||||
|
||||
export function useOfferAlert(offerHtml, topic) {
|
||||
const rawHtml = offerHtml || null;
|
||||
const isVisible = !!rawHtml; // if it exists, show it.
|
||||
|
||||
useAlert(isVisible, {
|
||||
code: 'clientOfferAlert',
|
||||
topic: 'course',
|
||||
topic,
|
||||
payload: { rawHtml },
|
||||
});
|
||||
|
||||
return { clientOfferAlert: OfferAlert };
|
||||
}
|
||||
|
||||
export default useOfferAlert;
|
||||
|
||||
@@ -1,2 +1 @@
|
||||
export { default as OfferAlert } from './OfferAlert';
|
||||
export { useOfferAlert } from './hooks';
|
||||
export { default } from './hooks';
|
||||
|
||||
@@ -9,6 +9,7 @@ Factory.define('courseHomeMetadata')
|
||||
.option('host', 'http://localhost:18000')
|
||||
.attrs({
|
||||
is_staff: false,
|
||||
original_user_is_staff: false,
|
||||
number: 'DemoX',
|
||||
org: 'edX',
|
||||
title: 'Demonstration Course',
|
||||
|
||||
@@ -5,6 +5,7 @@ import buildSimpleCourseBlocks from '../../../courseware/data/__factories__/cour
|
||||
Factory.define('outlineTabData')
|
||||
.option('courseId', 'course-v1:edX+DemoX+Demo_Course')
|
||||
.option('host', 'http://localhost:18000')
|
||||
.attr('course_expired_html', [], () => '<div>Course expired</div>')
|
||||
.attr('course_tools', ['host', 'courseId'], (host, courseId) => ({
|
||||
analytics_id: 'edx.bookmarks',
|
||||
title: 'Bookmarks',
|
||||
@@ -20,4 +21,5 @@ Factory.define('outlineTabData')
|
||||
can_enroll: true,
|
||||
extra_text: 'Contact the administrator.',
|
||||
})
|
||||
.attr('handouts_html', [], () => '<ul><li>Handout 1</li></ul>');
|
||||
.attr('handouts_html', [], () => '<ul><li>Handout 1</li></ul>')
|
||||
.attr('offer_html', [], () => '<div>Great offer here</div>');
|
||||
|
||||
@@ -1,212 +0,0 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`Data layer integration tests Should initialize store 1`] = `
|
||||
Object {
|
||||
"courseHome": Object {
|
||||
"courseId": null,
|
||||
"courseStatus": "loading",
|
||||
},
|
||||
"courseware": Object {
|
||||
"courseId": null,
|
||||
"courseStatus": "loading",
|
||||
"sequenceId": null,
|
||||
"sequenceStatus": "loading",
|
||||
},
|
||||
"models": Object {},
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Data layer integration tests Test fetchDatesTab Should fetch, normalize, and save metadata 1`] = `
|
||||
Object {
|
||||
"courseHome": Object {
|
||||
"courseId": "course-v1:edX+DemoX+Demo_Course_1",
|
||||
"courseStatus": "loaded",
|
||||
},
|
||||
"courseware": Object {
|
||||
"courseId": null,
|
||||
"courseStatus": "loading",
|
||||
"sequenceId": null,
|
||||
"sequenceStatus": "loading",
|
||||
},
|
||||
"models": Object {
|
||||
"courses": Object {
|
||||
"course-v1:edX+DemoX+Demo_Course_1": Object {
|
||||
"courseId": "course-v1:edX+DemoX+Demo_Course_1",
|
||||
"id": "course-v1:edX+DemoX+Demo_Course_1",
|
||||
"isSelfPaced": false,
|
||||
"isStaff": false,
|
||||
"number": "DemoX",
|
||||
"org": "edX",
|
||||
"tabs": Array [
|
||||
Object {
|
||||
"slug": "courseware",
|
||||
"title": "Course",
|
||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/course/",
|
||||
},
|
||||
Object {
|
||||
"slug": "discussion",
|
||||
"title": "Discussion",
|
||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/discussion/forum/",
|
||||
},
|
||||
Object {
|
||||
"slug": "wiki",
|
||||
"title": "Wiki",
|
||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/course_wiki",
|
||||
},
|
||||
Object {
|
||||
"slug": "progress",
|
||||
"title": "Progress",
|
||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/progress",
|
||||
},
|
||||
Object {
|
||||
"slug": "instructor",
|
||||
"title": "Instructor",
|
||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/instructor",
|
||||
},
|
||||
],
|
||||
"title": "Demonstration Course",
|
||||
},
|
||||
},
|
||||
"dates": Object {
|
||||
"course-v1:edX+DemoX+Demo_Course_1": Object {
|
||||
"courseDateBlocks": Array [
|
||||
Object {
|
||||
"assigmentType": "Homework",
|
||||
"date": "2013-02-05T05:00:00Z",
|
||||
"dateType": "course-start-date",
|
||||
"description": "",
|
||||
"extraInfo": "",
|
||||
"learnerHasAccess": true,
|
||||
"link": "",
|
||||
"title": "Course Starts",
|
||||
},
|
||||
],
|
||||
"datesBannerInfo": Object {
|
||||
"contentTypeGatingEnabled": false,
|
||||
"missedDeadlines": false,
|
||||
"missedGatedContent": false,
|
||||
},
|
||||
"id": "course-v1:edX+DemoX+Demo_Course_1",
|
||||
"learnerIsFullAccess": true,
|
||||
"missedDeadlines": false,
|
||||
"missedGatedContent": false,
|
||||
"userTimezone": null,
|
||||
"verifiedUpgradeLink": "http://localhost:18130/basket/add/?sku=8CF08E5",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Data layer integration tests Test fetchOutlineTab Should fetch, normalize, and save metadata 1`] = `
|
||||
Object {
|
||||
"courseHome": Object {
|
||||
"courseId": "course-v1:edX+DemoX+Demo_Course_1",
|
||||
"courseStatus": "loaded",
|
||||
},
|
||||
"courseware": Object {
|
||||
"courseId": null,
|
||||
"courseStatus": "loading",
|
||||
"sequenceId": null,
|
||||
"sequenceStatus": "loading",
|
||||
},
|
||||
"models": Object {
|
||||
"courses": Object {
|
||||
"course-v1:edX+DemoX+Demo_Course_1": Object {
|
||||
"courseId": "course-v1:edX+DemoX+Demo_Course_1",
|
||||
"id": "course-v1:edX+DemoX+Demo_Course_1",
|
||||
"isSelfPaced": false,
|
||||
"isStaff": false,
|
||||
"number": "DemoX",
|
||||
"org": "edX",
|
||||
"tabs": Array [
|
||||
Object {
|
||||
"slug": "courseware",
|
||||
"title": "Course",
|
||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/course/",
|
||||
},
|
||||
Object {
|
||||
"slug": "discussion",
|
||||
"title": "Discussion",
|
||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/discussion/forum/",
|
||||
},
|
||||
Object {
|
||||
"slug": "wiki",
|
||||
"title": "Wiki",
|
||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/course_wiki",
|
||||
},
|
||||
Object {
|
||||
"slug": "progress",
|
||||
"title": "Progress",
|
||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/progress",
|
||||
},
|
||||
Object {
|
||||
"slug": "instructor",
|
||||
"title": "Instructor",
|
||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/instructor",
|
||||
},
|
||||
],
|
||||
"title": "Demonstration Course",
|
||||
},
|
||||
},
|
||||
"outline": Object {
|
||||
"course-v1:edX+DemoX+Demo_Course_1": Object {
|
||||
"courseBlocks": Object {
|
||||
"courses": Object {
|
||||
"block-v1:edX+DemoX+Demo_Course+type@course+block@bcdabcdabcdabcdabcdabcdabcdabcd4": Object {
|
||||
"id": "course-v1:edX+DemoX+Demo_Course_1",
|
||||
"sectionIds": Array [
|
||||
"block-v1:edX+DemoX+Demo_Course+type@chapter+block@bcdabcdabcdabcdabcdabcdabcdabcd3",
|
||||
],
|
||||
"title": "bcdabcdabcdabcdabcdabcdabcdabcd4",
|
||||
},
|
||||
},
|
||||
"sections": Object {
|
||||
"block-v1:edX+DemoX+Demo_Course+type@chapter+block@bcdabcdabcdabcdabcdabcdabcdabcd3": Object {
|
||||
"courseId": "course-v1:edX+DemoX+Demo_Course_1",
|
||||
"id": "block-v1:edX+DemoX+Demo_Course+type@chapter+block@bcdabcdabcdabcdabcdabcdabcdabcd3",
|
||||
"sequenceIds": Array [
|
||||
"block-v1:edX+DemoX+Demo_Course+type@sequential+block@bcdabcdabcdabcdabcdabcdabcdabcd2",
|
||||
],
|
||||
"title": "bcdabcdabcdabcdabcdabcdabcdabcd3",
|
||||
},
|
||||
},
|
||||
"sequences": Object {
|
||||
"block-v1:edX+DemoX+Demo_Course+type@sequential+block@bcdabcdabcdabcdabcdabcdabcdabcd2": Object {
|
||||
"id": "block-v1:edX+DemoX+Demo_Course+type@sequential+block@bcdabcdabcdabcdabcdabcdabcdabcd2",
|
||||
"lmsWebUrl": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@sequential+block@bcdabcdabcdabcdabcdabcdabcdabcd2",
|
||||
"sectionId": "block-v1:edX+DemoX+Demo_Course+type@chapter+block@bcdabcdabcdabcdabcdabcdabcdabcd3",
|
||||
"title": "bcdabcdabcdabcdabcdabcdabcdabcd2",
|
||||
"unitIds": Array [
|
||||
"block-v1:edX+DemoX+Demo_Course+type@vertical+block@bcdabcdabcdabcdabcdabcdabcdabcd1",
|
||||
],
|
||||
},
|
||||
},
|
||||
"units": Object {
|
||||
"block-v1:edX+DemoX+Demo_Course+type@vertical+block@bcdabcdabcdabcdabcdabcdabcdabcd1": Object {
|
||||
"graded": false,
|
||||
"id": "block-v1:edX+DemoX+Demo_Course+type@vertical+block@bcdabcdabcdabcdabcdabcdabcdabcd1",
|
||||
"lmsWebUrl": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@vertical+block@bcdabcdabcdabcdabcdabcdabcdabcd1",
|
||||
"sequenceId": "block-v1:edX+DemoX+Demo_Course+type@sequential+block@bcdabcdabcdabcdabcdabcdabcdabcd2",
|
||||
"title": "bcdabcdabcdabcdabcdabcdabcdabcd1",
|
||||
},
|
||||
},
|
||||
},
|
||||
"courseTools": Object {
|
||||
"analyticsId": "edx.bookmarks",
|
||||
"title": "Bookmarks",
|
||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/bookmarks/",
|
||||
},
|
||||
"datesWidget": undefined,
|
||||
"enrollAlert": Object {
|
||||
"canEnroll": true,
|
||||
"extraText": "Contact the administrator.",
|
||||
},
|
||||
"handoutsHtml": "<ul><li>Handout 1</li></ul>",
|
||||
"id": "course-v1:edX+DemoX+Demo_Course_1",
|
||||
"welcomeMessageHtml": undefined,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
`;
|
||||
@@ -69,18 +69,22 @@ export async function getOutlineTabData(courseId) {
|
||||
data,
|
||||
} = tabData;
|
||||
const courseBlocks = normalizeBlocks(courseId, data.course_blocks.blocks);
|
||||
const courseExpiredHtml = data.course_expired_html;
|
||||
const courseTools = camelCaseObject(data.course_tools);
|
||||
const datesWidget = camelCaseObject(data.dates_widget);
|
||||
const enrollAlert = camelCaseObject(data.enroll_alert);
|
||||
const handoutsHtml = data.handouts_html;
|
||||
const offerHtml = data.offer_html;
|
||||
const welcomeMessageHtml = data.welcome_message_html;
|
||||
|
||||
return {
|
||||
courseTools,
|
||||
courseBlocks,
|
||||
courseExpiredHtml,
|
||||
courseTools,
|
||||
datesWidget,
|
||||
enrollAlert,
|
||||
handoutsHtml,
|
||||
offerHtml,
|
||||
welcomeMessageHtml,
|
||||
};
|
||||
}
|
||||
@@ -94,3 +98,8 @@ export async function postDismissWelcomeMessage(courseId) {
|
||||
const url = new URL(`${getConfig().LMS_BASE_URL}/api/course_home/v1/dismiss_welcome_message`);
|
||||
await getAuthenticatedHttpClient().post(url.href, { course_id: courseId });
|
||||
}
|
||||
|
||||
export async function postRequestCert(courseId) {
|
||||
const url = new URL(`${getConfig().LMS_BASE_URL}/courses/${courseId}/generate_user_cert`);
|
||||
await getAuthenticatedHttpClient().post(url.href);
|
||||
}
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import { Factory } from 'rosie';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
|
||||
import * as thunks from './thunks';
|
||||
|
||||
import executeThunk from '../../utils';
|
||||
|
||||
import { COURSE_LOADED, COURSE_LOADING, COURSE_FAILED } from '../../active-course';
|
||||
import initializeMockApp from '../../setupTest';
|
||||
import initializeStore from '../../store';
|
||||
import executeThunk from '../../utils';
|
||||
|
||||
import * as thunks from './thunks';
|
||||
|
||||
const { loggingService } = initializeMockApp();
|
||||
|
||||
@@ -41,7 +40,8 @@ describe('Data layer integration tests', () => {
|
||||
});
|
||||
|
||||
it('Should initialize store', () => {
|
||||
expect(store.getState()).toMatchSnapshot();
|
||||
expect(store.getState().activeCourse.courseId).toBeNull();
|
||||
expect(store.getState().activeCourse.courseStatus).toEqual(COURSE_LOADING);
|
||||
});
|
||||
|
||||
describe('Test fetchDatesTab', () => {
|
||||
@@ -55,7 +55,7 @@ describe('Data layer integration tests', () => {
|
||||
await executeThunk(thunks.fetchDatesTab(courseId), store.dispatch);
|
||||
|
||||
expect(loggingService.logError).toHaveBeenCalled();
|
||||
expect(store.getState().courseHome.courseStatus).toEqual('failed');
|
||||
expect(store.getState().activeCourse.courseStatus).toEqual(COURSE_FAILED);
|
||||
});
|
||||
|
||||
it('Should fetch, normalize, and save metadata', async () => {
|
||||
@@ -70,8 +70,31 @@ describe('Data layer integration tests', () => {
|
||||
await executeThunk(thunks.fetchDatesTab(courseId), store.dispatch);
|
||||
|
||||
const state = store.getState();
|
||||
expect(state.courseHome.courseStatus).toEqual('loaded');
|
||||
expect(state).toMatchSnapshot();
|
||||
expect(state.activeCourse.courseStatus).toEqual(COURSE_LOADED);
|
||||
expect(state.activeCourse.courseId).toEqual(courseId);
|
||||
expect(state.courseHome.displayResetDatesToast).toBe(false);
|
||||
|
||||
// Validate course
|
||||
const course = state.models.courses[courseId];
|
||||
const expectedFieldCount = Object.keys(course).length;
|
||||
// If this breaks, you should consider adding assertions below for the new data. If it's not
|
||||
// an "interesting" addition, just bump the number anyway.
|
||||
expect(expectedFieldCount).toBe(9);
|
||||
expect(course.title).toEqual(courseHomeMetadata.title);
|
||||
|
||||
// Representative sample of data that proves data normalization and ingestion happened.
|
||||
expect(course.id).toEqual(courseId);
|
||||
expect(course.isStaff).toBe(courseHomeMetadata.is_staff);
|
||||
expect(course.number).toEqual(courseHomeMetadata.number);
|
||||
expect(Array.isArray(course.tabs)).toBe(true);
|
||||
expect(course.tabs.length).toBe(5); // Weak assertion, but proves the array made it through.
|
||||
|
||||
// This proves the tab type came through as a modelType. We don't need to assert much else
|
||||
// here because the shape of this data is not passed through any sort of normalization scheme,
|
||||
// it just gets camelCased.
|
||||
const dates = state.models.dates[courseId];
|
||||
expect(dates.id).toEqual(courseId);
|
||||
expect(dates.verifiedUpgradeLink).toBe(datesTabData.verified_upgrade_link);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -86,7 +109,7 @@ describe('Data layer integration tests', () => {
|
||||
await executeThunk(thunks.fetchOutlineTab(courseId), store.dispatch);
|
||||
|
||||
expect(loggingService.logError).toHaveBeenCalled();
|
||||
expect(store.getState().courseHome.courseStatus).toEqual('failed');
|
||||
expect(store.getState().activeCourse.courseStatus).toEqual(COURSE_FAILED);
|
||||
});
|
||||
|
||||
it('Should fetch, normalize, and save metadata', async () => {
|
||||
@@ -101,8 +124,30 @@ describe('Data layer integration tests', () => {
|
||||
await executeThunk(thunks.fetchOutlineTab(courseId), store.dispatch);
|
||||
|
||||
const state = store.getState();
|
||||
expect(state.courseHome.courseStatus).toEqual('loaded');
|
||||
expect(state).toMatchSnapshot();
|
||||
expect(state.activeCourse.courseStatus).toEqual(COURSE_LOADED);
|
||||
expect(state.courseHome.displayResetDatesToast).toBe(false);
|
||||
|
||||
// Validate course
|
||||
const course = state.models.courses[courseId];
|
||||
const expectedFieldCount = Object.keys(course).length;
|
||||
// If this breaks, you should consider adding assertions below for the new data. If it's not
|
||||
// an "interesting" addition, just bump the number anyway.
|
||||
expect(expectedFieldCount).toBe(9);
|
||||
expect(course.title).toEqual(courseHomeMetadata.title);
|
||||
|
||||
// Representative sample of data that proves data normalization and ingestion happened.
|
||||
expect(course.id).toEqual(courseId);
|
||||
expect(course.isStaff).toBe(courseHomeMetadata.is_staff);
|
||||
expect(course.number).toEqual(courseHomeMetadata.number);
|
||||
expect(Array.isArray(course.tabs)).toBe(true);
|
||||
expect(course.tabs.length).toBe(5); // Weak assertion, but proves the array made it through.
|
||||
|
||||
// This proves the tab type came through as a modelType. We don't need to assert much else
|
||||
// here because the shape of this data is not passed through any sort of normalization scheme,
|
||||
// it just gets camelCased.
|
||||
const outline = state.models.outline[courseId];
|
||||
expect(outline.id).toEqual(courseId);
|
||||
expect(outline.handoutsHtml).toBe(outlineTabData.handouts_html);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,36 +1,20 @@
|
||||
/* 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: 'course-home',
|
||||
initialState: {
|
||||
courseStatus: 'loading',
|
||||
courseId: null,
|
||||
displayResetDatesToast: false,
|
||||
},
|
||||
reducers: {
|
||||
fetchTabRequest: (state, { payload }) => {
|
||||
state.courseId = payload.courseId;
|
||||
state.courseStatus = LOADING;
|
||||
},
|
||||
fetchTabSuccess: (state, { payload }) => {
|
||||
state.courseId = payload.courseId;
|
||||
state.courseStatus = LOADED;
|
||||
},
|
||||
fetchTabFailure: (state, { payload }) => {
|
||||
state.courseId = payload.courseId;
|
||||
state.courseStatus = FAILED;
|
||||
toggleResetDatesToast: (state, { payload }) => {
|
||||
state.displayResetDatesToast = payload.displayResetDatesToast;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const {
|
||||
fetchTabRequest,
|
||||
fetchTabSuccess,
|
||||
fetchTabFailure,
|
||||
toggleResetDatesToast,
|
||||
} = slice.actions;
|
||||
|
||||
export const {
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
getProgressTabData,
|
||||
postCourseDeadlines,
|
||||
postDismissWelcomeMessage,
|
||||
postRequestCert,
|
||||
} from './api';
|
||||
|
||||
import {
|
||||
@@ -13,14 +14,19 @@ import {
|
||||
} from '../../generic/model-store';
|
||||
|
||||
import {
|
||||
fetchTabFailure,
|
||||
fetchTabRequest,
|
||||
fetchTabSuccess,
|
||||
fetchCourseRequest,
|
||||
fetchCourseSuccess,
|
||||
fetchCourseFailure,
|
||||
} from '../../active-course';
|
||||
|
||||
import {
|
||||
toggleResetDatesToast,
|
||||
} from './slice';
|
||||
|
||||
export function fetchTab(courseId, tab, getTabData) {
|
||||
return async (dispatch) => {
|
||||
dispatch(fetchTabRequest({ courseId }));
|
||||
dispatch(fetchCourseRequest({ courseId }));
|
||||
|
||||
Promise.allSettled([
|
||||
getCourseHomeCourseMetadata(courseId),
|
||||
getTabData(courseId),
|
||||
@@ -53,9 +59,9 @@ export function fetchTab(courseId, tab, getTabData) {
|
||||
}
|
||||
|
||||
if (fetchedCourseHomeCourseMetadata && fetchedTabData) {
|
||||
dispatch(fetchTabSuccess({ courseId }));
|
||||
dispatch(fetchCourseSuccess({ courseId }));
|
||||
} else {
|
||||
dispatch(fetchTabFailure({ courseId }));
|
||||
dispatch(fetchCourseFailure({ courseId }));
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -77,6 +83,7 @@ export function resetDeadlines(courseId, getTabData) {
|
||||
return async (dispatch) => {
|
||||
postCourseDeadlines(courseId).then(() => {
|
||||
dispatch(getTabData(courseId));
|
||||
dispatch(toggleResetDatesToast({ displayResetDatesToast: true }));
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -84,3 +91,7 @@ export function resetDeadlines(courseId, getTabData) {
|
||||
export function dismissWelcomeMessage(courseId) {
|
||||
return async () => postDismissWelcomeMessage(courseId);
|
||||
}
|
||||
|
||||
export function requestCert(courseId) {
|
||||
return async () => postRequestCert(courseId);
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ function DatesBannerContainer(props) {
|
||||
|
||||
const {
|
||||
courseId,
|
||||
} = useSelector(state => state.courseHome);
|
||||
} = useSelector(state => state.activeCourse);
|
||||
|
||||
const {
|
||||
courseDateBlocks,
|
||||
|
||||
@@ -14,7 +14,7 @@ function Day({
|
||||
}) {
|
||||
const {
|
||||
courseId,
|
||||
} = useSelector(state => state.courseHome);
|
||||
} = useSelector(state => state.activeCourse);
|
||||
|
||||
const {
|
||||
userTimezone,
|
||||
|
||||
@@ -9,7 +9,7 @@ import { daycmp, isLearnerAssignment } from './utils';
|
||||
export default function Timeline() {
|
||||
const {
|
||||
courseId,
|
||||
} = useSelector(state => state.courseHome);
|
||||
} = useSelector(state => state.activeCourse);
|
||||
|
||||
const {
|
||||
courseDateBlocks,
|
||||
|
||||
2
src/course-home/index.js
Normal file
2
src/course-home/index.js
Normal file
@@ -0,0 +1,2 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
export { reducer } from './data';
|
||||
@@ -10,18 +10,20 @@ import CourseHandouts from './widgets/CourseHandouts';
|
||||
import CourseTools from './widgets/CourseTools';
|
||||
import messages from './messages';
|
||||
import Section from './Section';
|
||||
import useAccessExpirationAlert from '../../alerts/access-expiration-alert';
|
||||
import useCertificateAvailableAlert from './alerts/certificate-available-alert';
|
||||
import useCourseEndAlert from './alerts/course-end-alert';
|
||||
import useCourseStartAlert from './alerts/course-start-alert';
|
||||
import useEnrollmentAlert from '../../alerts/enrollment-alert';
|
||||
import useLogistrationAlert from '../../alerts/logistration-alert';
|
||||
import useOfferAlert from '../../alerts/offer-alert';
|
||||
import { useModel } from '../../generic/model-store';
|
||||
import WelcomeMessage from './widgets/WelcomeMessage';
|
||||
|
||||
function OutlineTab({ intl }) {
|
||||
const {
|
||||
courseId,
|
||||
} = useSelector(state => state.courseHome);
|
||||
} = useSelector(state => state.activeCourse);
|
||||
|
||||
const {
|
||||
title,
|
||||
@@ -38,13 +40,20 @@ function OutlineTab({ intl }) {
|
||||
courses,
|
||||
sections,
|
||||
},
|
||||
courseExpiredHtml,
|
||||
offerHtml,
|
||||
} = useModel('outline', courseId);
|
||||
|
||||
const certificateAvailableAlert = useCertificateAvailableAlert(courseId);
|
||||
const courseEndAlert = useCourseEndAlert(courseId);
|
||||
const courseStartAlert = useCourseStartAlert(courseId);
|
||||
const enrollmentAlert = useEnrollmentAlert(courseId);
|
||||
// Above the tab alerts (appearing in the order listed here)
|
||||
const logistrationAlert = useLogistrationAlert();
|
||||
const enrollmentAlert = useEnrollmentAlert(courseId);
|
||||
|
||||
// Below the course title alerts (appearing in the order listed here)
|
||||
const offerAlert = useOfferAlert(offerHtml, 'outline-course-alerts');
|
||||
const accessExpirationAlert = useAccessExpirationAlert(courseExpiredHtml, 'outline-course-alerts');
|
||||
const courseStartAlert = useCourseStartAlert(courseId);
|
||||
const courseEndAlert = useCourseEndAlert(courseId);
|
||||
const certificateAvailableAlert = useCertificateAvailableAlert(courseId);
|
||||
|
||||
const rootCourseId = Object.keys(courses)[0];
|
||||
const { sectionIds } = courses[rootCourseId];
|
||||
@@ -70,9 +79,11 @@ function OutlineTab({ intl }) {
|
||||
topic="outline-course-alerts"
|
||||
className="mb-3"
|
||||
customAlerts={{
|
||||
...accessExpirationAlert,
|
||||
...certificateAvailableAlert,
|
||||
...courseEndAlert,
|
||||
...courseStartAlert,
|
||||
...offerAlert,
|
||||
}}
|
||||
/>
|
||||
{sectionIds.map((sectionId) => (
|
||||
|
||||
73
src/course-home/progress-tab/CertificateBanner.jsx
Normal file
73
src/course-home/progress-tab/CertificateBanner.jsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import React from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { requestCert } from '../data/thunks';
|
||||
|
||||
import { useModel } from '../../generic/model-store';
|
||||
import messages from './messages';
|
||||
import VerifiedCert from '../../courseware/course/sequence/lock-paywall/assets/edx-verified-mini-cert.png';
|
||||
|
||||
function CertificateBanner({ intl }) {
|
||||
const {
|
||||
courseId,
|
||||
} = useSelector(state => state.courseHome);
|
||||
|
||||
const {
|
||||
certificateData,
|
||||
enrollmentMode,
|
||||
} = useModel('progress', courseId);
|
||||
if (certificateData === null || enrollmentMode === 'audit') { return null; }
|
||||
const { certUrl, certDownloadUrl } = certificateData;
|
||||
const dispatch = useDispatch();
|
||||
function requestHandler() {
|
||||
dispatch(requestCert(courseId));
|
||||
}
|
||||
return (
|
||||
<section className="banner rounded my-4 p-4 container-fluid border border-primary-200 bg-info-100 row">
|
||||
<div className="col-12 col-sm-9">
|
||||
<div>
|
||||
<div className="font-weight-bold">{certificateData.title}</div>
|
||||
<div className="mt-1">{certificateData.msg}</div>
|
||||
</div>
|
||||
{certUrl && (
|
||||
<div>
|
||||
<a className="btn btn-primary my-3" href={certUrl} rel="noopener noreferrer" target="_blank">
|
||||
{intl.formatMessage(messages.viewCert)}
|
||||
<span className="sr-only">{intl.formatMessage(messages.opensNewWindow)}</span>
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
{!certUrl && certificateData.isDownloadable && (
|
||||
<div>
|
||||
<a className="btn btn-primary my-3" href={certDownloadUrl} rel="noopener noreferrer" target="_blank">
|
||||
{intl.formatMessage(messages.downloadCert)}
|
||||
<span className="sr-only">{intl.formatMessage(messages.opensNewWindow)}</span>
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
{!certUrl && !certificateData.isDownloadable && certificateData.isRequestable && (
|
||||
<div className="my-3">
|
||||
<button className="btn btn-primary" type="button" onClick={requestHandler}>
|
||||
{intl.formatMessage(messages.requestCert)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="col-0 col-sm-3 d-none d-sm-block">
|
||||
<img
|
||||
alt={intl.formatMessage(messages.certAlt)}
|
||||
src={VerifiedCert}
|
||||
className="float-right"
|
||||
style={{ height: '120px' }}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
CertificateBanner.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(CertificateBanner);
|
||||
@@ -9,7 +9,7 @@ export default function DueDateTime({
|
||||
}) {
|
||||
const {
|
||||
courseId,
|
||||
} = useSelector(state => state.courseHome);
|
||||
} = useSelector(state => state.activeCourse);
|
||||
const {
|
||||
userTimezone,
|
||||
} = useModel('progress', courseId);
|
||||
|
||||
0
src/course-home/progress-tab/ProgressGraph.jsx
Normal file
0
src/course-home/progress-tab/ProgressGraph.jsx
Normal file
@@ -1,24 +1,34 @@
|
||||
import React from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { useModel } from '../../generic/model-store';
|
||||
import Chapter from './Chapter';
|
||||
import CertificateBanner from './CertificateBanner';
|
||||
import messages from './messages';
|
||||
|
||||
export default function ProgressTab() {
|
||||
function ProgressTab({ intl }) {
|
||||
const {
|
||||
courseId,
|
||||
} = useSelector(state => state.courseHome);
|
||||
} = useSelector(state => state.activeCourse);
|
||||
|
||||
const { administrator, username } = getAuthenticatedUser();
|
||||
const { administrator } = getAuthenticatedUser();
|
||||
|
||||
const {
|
||||
enrollmentMode,
|
||||
coursewareSummary,
|
||||
studioUrl,
|
||||
} = useModel('progress', courseId);
|
||||
|
||||
return (
|
||||
<section>
|
||||
{enrollmentMode} {administrator} {username}
|
||||
{administrator && studioUrl && (
|
||||
<div className="row mb-3 mr-3 justify-content-end">
|
||||
<a className="btn-sm border border-info" href={studioUrl}>
|
||||
{intl.formatMessage(messages.studioLink)}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
<CertificateBanner />
|
||||
{coursewareSummary.map((chapter) => (
|
||||
<Chapter
|
||||
key={chapter.displayName}
|
||||
@@ -28,3 +38,9 @@ export default function ProgressTab() {
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
ProgressTab.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(ProgressTab);
|
||||
|
||||
@@ -33,6 +33,31 @@ const messages = defineMessages({
|
||||
id: 'learning.progress.badge.scoreEarned',
|
||||
defaultMessage: '{earned} of {total} possible points',
|
||||
},
|
||||
viewCert: {
|
||||
id: 'learning.progress.badge.viewCert',
|
||||
defaultMessage: 'View Certificate',
|
||||
},
|
||||
downloadCert: {
|
||||
id: 'learning.progress.badge.downloadCert',
|
||||
defaultMessage: 'Download Your Certificate',
|
||||
},
|
||||
requestCert: {
|
||||
id: 'learning.progress.badge.requestCert',
|
||||
defaultMessage: 'Request Certificate',
|
||||
},
|
||||
opensNewWindow: {
|
||||
id: 'learning.progress.badge.opensNewWindow',
|
||||
defaultMessage: 'Opens in a new browser window',
|
||||
},
|
||||
certAlt: {
|
||||
id: 'learning.progress.badge.certAlt',
|
||||
defaultMessage: 'Example Certificate',
|
||||
description: 'Alternate text displayed when the example certificate image cannot be displayed.',
|
||||
},
|
||||
studioLink: {
|
||||
id: 'learning.progress.badge.studioLink',
|
||||
defaultMessage: 'View grading in studio',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
|
||||
@@ -13,14 +13,20 @@ import {
|
||||
fetchSequence,
|
||||
getResumeBlock,
|
||||
saveSequencePosition,
|
||||
SEQUENCE_LOADED,
|
||||
SEQUENCE_LOADING,
|
||||
SEQUENCE_FAILED,
|
||||
} from './data';
|
||||
import { TabPage } from '../tab-page';
|
||||
|
||||
import {
|
||||
activeCourseSelector, COURSE_LOADED, COURSE_LOADING, COURSE_FAILED, COURSE_DENIED,
|
||||
} from '../active-course';
|
||||
import Course from './course';
|
||||
import { handleNextSectionCelebration } from './course/celebration';
|
||||
|
||||
const checkExamRedirect = memoize((sequenceStatus, sequence) => {
|
||||
if (sequenceStatus === 'loaded') {
|
||||
if (sequenceStatus === SEQUENCE_LOADED) {
|
||||
if (sequence.isTimeLimited && sequence.lmsWebUrl !== undefined) {
|
||||
global.location.assign(sequence.lmsWebUrl);
|
||||
}
|
||||
@@ -28,7 +34,7 @@ const checkExamRedirect = memoize((sequenceStatus, sequence) => {
|
||||
});
|
||||
|
||||
const checkResumeRedirect = memoize((courseStatus, courseId, sequenceId, firstSequenceId) => {
|
||||
if (courseStatus === 'loaded' && !sequenceId) {
|
||||
if (courseStatus === COURSE_LOADED && !sequenceId) {
|
||||
// Note that getResumeBlock is just an API call, not a redux thunk.
|
||||
getResumeBlock(courseId).then((data) => {
|
||||
// This is a replace because we don't want this change saved in the browser's history.
|
||||
@@ -42,7 +48,7 @@ const checkResumeRedirect = memoize((courseStatus, courseId, sequenceId, firstSe
|
||||
});
|
||||
|
||||
const checkContentRedirect = memoize((courseId, sequenceStatus, sequenceId, sequence, unitId) => {
|
||||
if (sequenceStatus === 'loaded' && sequenceId && !unitId) {
|
||||
if (sequenceStatus === SEQUENCE_LOADED && sequenceId && !unitId) {
|
||||
if (sequence.unitIds !== undefined && sequence.unitIds.length > 0) {
|
||||
const nextUnitId = sequence.unitIds[sequence.activeUnitIndex];
|
||||
// This is a replace because we don't want this change saved in the browser's history.
|
||||
@@ -59,7 +65,7 @@ class CoursewareContainer extends Component {
|
||||
sequenceStatus,
|
||||
sequence,
|
||||
} = this.props;
|
||||
if (sequenceStatus === 'loaded' && sequence.saveUnitPosition && unitId) {
|
||||
if (sequenceStatus === SEQUENCE_LOADED && sequence.saveUnitPosition && unitId) {
|
||||
const activeUnitIndex = sequence.unitIds.indexOf(unitId);
|
||||
this.props.saveSequencePosition(courseId, sequenceId, activeUnitIndex);
|
||||
}
|
||||
@@ -207,7 +213,7 @@ class CoursewareContainer extends Component {
|
||||
},
|
||||
} = this.props;
|
||||
|
||||
if (courseStatus === 'denied') {
|
||||
if (courseStatus === COURSE_DENIED) {
|
||||
return this.renderDenied();
|
||||
}
|
||||
|
||||
@@ -257,8 +263,8 @@ CoursewareContainer.propTypes = {
|
||||
sequenceId: PropTypes.string,
|
||||
firstSequenceId: PropTypes.string,
|
||||
unitId: PropTypes.string,
|
||||
courseStatus: PropTypes.oneOf(['loaded', 'loading', 'failed', 'denied']).isRequired,
|
||||
sequenceStatus: PropTypes.oneOf(['loaded', 'loading', 'failed']).isRequired,
|
||||
courseStatus: PropTypes.oneOf([COURSE_LOADED, COURSE_LOADING, COURSE_FAILED, COURSE_DENIED]).isRequired,
|
||||
sequenceStatus: PropTypes.oneOf([SEQUENCE_LOADED, SEQUENCE_LOADING, SEQUENCE_FAILED]).isRequired,
|
||||
nextSequence: sequenceShape,
|
||||
previousSequence: sequenceShape,
|
||||
course: courseShape,
|
||||
@@ -280,12 +286,6 @@ CoursewareContainer.defaultProps = {
|
||||
sequence: null,
|
||||
};
|
||||
|
||||
const currentCourseSelector = createSelector(
|
||||
(state) => state.models.courses || {},
|
||||
(state) => state.courseware.courseId,
|
||||
(coursesById, courseId) => (coursesById[courseId] ? coursesById[courseId] : null),
|
||||
);
|
||||
|
||||
const currentSequenceSelector = createSelector(
|
||||
(state) => state.models.sequences || {},
|
||||
(state) => state.courseware.sequenceId,
|
||||
@@ -293,11 +293,11 @@ const currentSequenceSelector = createSelector(
|
||||
);
|
||||
|
||||
const sequenceIdsSelector = createSelector(
|
||||
(state) => state.courseware.courseStatus,
|
||||
currentCourseSelector,
|
||||
(state) => state.activeCourse.courseStatus,
|
||||
activeCourseSelector,
|
||||
(state) => state.models.sections,
|
||||
(courseStatus, course, sectionsById) => {
|
||||
if (courseStatus !== 'loaded') {
|
||||
if (courseStatus !== COURSE_LOADED) {
|
||||
return [];
|
||||
}
|
||||
const { sectionIds = [] } = course;
|
||||
@@ -334,11 +334,11 @@ const nextSequenceSelector = createSelector(
|
||||
);
|
||||
|
||||
const firstSequenceIdSelector = createSelector(
|
||||
(state) => state.courseware.courseStatus,
|
||||
currentCourseSelector,
|
||||
(state) => state.activeCourse.courseStatus,
|
||||
activeCourseSelector,
|
||||
(state) => state.models.sections || {},
|
||||
(courseStatus, course, sectionsById) => {
|
||||
if (courseStatus !== 'loaded') {
|
||||
if (courseStatus !== COURSE_LOADED) {
|
||||
return null;
|
||||
}
|
||||
const { sectionIds = [] } = course;
|
||||
@@ -353,8 +353,11 @@ const firstSequenceIdSelector = createSelector(
|
||||
|
||||
const mapStateToProps = (state) => {
|
||||
const {
|
||||
courseId, sequenceId, unitId, courseStatus, sequenceStatus,
|
||||
sequenceId, sequenceStatus, unitId,
|
||||
} = state.courseware;
|
||||
const {
|
||||
courseId, courseStatus,
|
||||
} = state.activeCourse;
|
||||
|
||||
return {
|
||||
courseId,
|
||||
@@ -362,7 +365,7 @@ const mapStateToProps = (state) => {
|
||||
unitId,
|
||||
courseStatus,
|
||||
sequenceStatus,
|
||||
course: currentCourseSelector(state),
|
||||
course: activeCourseSelector(state),
|
||||
sequence: currentSequenceSelector(state),
|
||||
previousSequence: previousSequenceSelector(state),
|
||||
nextSequence: nextSequenceSelector(state),
|
||||
|
||||
@@ -2,7 +2,7 @@ import React from 'react';
|
||||
import { Switch, Route, useRouteMatch } from 'react-router';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||
import PageLoading from './generic/PageLoading';
|
||||
import PageLoading from '../generic/PageLoading';
|
||||
|
||||
export default () => {
|
||||
const { path } = useRouteMatch();
|
||||
@@ -5,8 +5,8 @@ import { useDispatch } from 'react-redux';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
|
||||
import { AlertList } from '../../generic/user-messages';
|
||||
import { useAccessExpirationAlert } from '../../alerts/access-expiration-alert';
|
||||
import { useOfferAlert } from '../../alerts/offer-alert';
|
||||
import useAccessExpirationAlert from '../../alerts/access-expiration-alert';
|
||||
import useOfferAlert from '../../alerts/offer-alert';
|
||||
|
||||
import Sequence from './sequence';
|
||||
|
||||
@@ -16,13 +16,6 @@ import CourseSock from './course-sock';
|
||||
import ContentTools from './content-tools';
|
||||
import { useModel } from '../../generic/model-store';
|
||||
|
||||
// Note that we import from the component files themselves in the enrollment-alert package.
|
||||
// This is because Reacy.lazy() requires that we import() from a file with a Component as it's
|
||||
// default export.
|
||||
// See React.lazy docs here: https://reactjs.org/docs/code-splitting.html#reactlazy
|
||||
const AccessExpirationAlert = React.lazy(() => import('../../alerts/access-expiration-alert/AccessExpirationAlert'));
|
||||
const OfferAlert = React.lazy(() => import('../../alerts/offer-alert/OfferAlert'));
|
||||
|
||||
function Course({
|
||||
courseId,
|
||||
sequenceId,
|
||||
@@ -41,15 +34,18 @@ function Course({
|
||||
course,
|
||||
].filter(element => element != null).map(element => element.title);
|
||||
|
||||
useOfferAlert(courseId);
|
||||
useAccessExpirationAlert(courseId);
|
||||
|
||||
const {
|
||||
canShowUpgradeSock,
|
||||
celebrations,
|
||||
courseExpiredMessage,
|
||||
offerHtml,
|
||||
verifiedMode,
|
||||
} = course;
|
||||
|
||||
// Below the tabs, above the breadcrumbs alerts (appearing in the order listed here)
|
||||
const offerAlert = useOfferAlert(offerHtml, 'course');
|
||||
const accessExpirationAlert = useAccessExpirationAlert(courseExpiredMessage, 'course');
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const celebrateFirstSection = celebrations && celebrations.firstSection;
|
||||
const celebrationOpen = shouldCelebrateOnSectionLoad(courseId, sequenceId, unitId, celebrateFirstSection, dispatch);
|
||||
@@ -63,8 +59,8 @@ function Course({
|
||||
className="my-3"
|
||||
topic="course"
|
||||
customAlerts={{
|
||||
clientAccessExpirationAlert: AccessExpirationAlert,
|
||||
clientOfferAlert: OfferAlert,
|
||||
...accessExpirationAlert,
|
||||
...offerAlert,
|
||||
}}
|
||||
/>
|
||||
<CourseBreadcrumbs
|
||||
|
||||
@@ -6,6 +6,8 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faHome } from '@fortawesome/free-solid-svg-icons';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useModel } from '../../generic/model-store';
|
||||
import { COURSE_LOADED } from '../../active-course';
|
||||
import { SEQUENCE_LOADED } from '../data';
|
||||
|
||||
function CourseBreadcrumb({
|
||||
url, children, withSeparator, ...attrs
|
||||
@@ -40,11 +42,11 @@ export default function CourseBreadcrumbs({
|
||||
const course = useModel('courses', courseId);
|
||||
const sequence = useModel('sequences', sequenceId);
|
||||
const section = useModel('sections', sectionId);
|
||||
const courseStatus = useSelector(state => state.courseware.courseStatus);
|
||||
const courseStatus = useSelector(state => state.activeCourse.courseStatus);
|
||||
const sequenceStatus = useSelector(state => state.courseware.sequenceStatus);
|
||||
|
||||
const links = useMemo(() => {
|
||||
if (courseStatus === 'loaded' && sequenceStatus === 'loaded') {
|
||||
if (courseStatus === COURSE_LOADED && sequenceStatus === SEQUENCE_LOADED) {
|
||||
return [section, sequence].filter(node => !!node).map((node) => ({
|
||||
id: node.id,
|
||||
label: node.title,
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
|
||||
import { getAuthenticatedHttpClient, getAuthenticatedUser } from '@edx/frontend-platform/auth';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
|
||||
import * as thunks from './thunks';
|
||||
|
||||
import executeThunk from '../../../../utils';
|
||||
|
||||
import initializeMockApp from '../../../../setupTest';
|
||||
import initializeStore from '../../../../store';
|
||||
|
||||
import {
|
||||
addBookmark,
|
||||
removeBookmark,
|
||||
BOOKMARK_FAILED,
|
||||
BOOKMARK_LOADED,
|
||||
} from './thunks';
|
||||
|
||||
const { loggingService } = initializeMockApp();
|
||||
|
||||
const axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
@@ -32,24 +35,24 @@ describe('Data layer integration tests', () => {
|
||||
it('Should fail to create bookmark in case of error', async () => {
|
||||
axiosMock.onPost(createBookmarkURL).networkError();
|
||||
|
||||
await executeThunk(thunks.addBookmark(unitId), store.dispatch);
|
||||
await executeThunk(addBookmark(unitId), store.dispatch);
|
||||
|
||||
expect(loggingService.logError).toHaveBeenCalled();
|
||||
expect(axiosMock.history.post[0].url).toEqual(createBookmarkURL);
|
||||
expect(store.getState().models.units[unitId]).toEqual(expect.objectContaining({
|
||||
bookmarked: false,
|
||||
bookmarkedUpdateState: 'failed',
|
||||
bookmarkedUpdateState: BOOKMARK_FAILED,
|
||||
}));
|
||||
});
|
||||
|
||||
it('Should create bookmark and update model state', async () => {
|
||||
axiosMock.onPost(createBookmarkURL).reply(201);
|
||||
|
||||
await executeThunk(thunks.addBookmark(unitId), store.dispatch);
|
||||
await executeThunk(addBookmark(unitId), store.dispatch);
|
||||
|
||||
expect(store.getState().models.units[unitId]).toEqual(expect.objectContaining({
|
||||
bookmarked: true,
|
||||
bookmarkedUpdateState: 'loaded',
|
||||
bookmarkedUpdateState: BOOKMARK_LOADED,
|
||||
}));
|
||||
});
|
||||
});
|
||||
@@ -60,24 +63,24 @@ describe('Data layer integration tests', () => {
|
||||
it('Should fail to remove bookmark in case of error', async () => {
|
||||
axiosMock.onDelete(deleteBookmarkURL).networkError();
|
||||
|
||||
await executeThunk(thunks.removeBookmark(unitId), store.dispatch);
|
||||
await executeThunk(removeBookmark(unitId), store.dispatch);
|
||||
|
||||
expect(loggingService.logError).toHaveBeenCalled();
|
||||
expect(axiosMock.history.delete[0].url).toEqual(deleteBookmarkURL);
|
||||
expect(store.getState().models.units[unitId]).toEqual(expect.objectContaining({
|
||||
bookmarked: true,
|
||||
bookmarkedUpdateState: 'failed',
|
||||
bookmarkedUpdateState: BOOKMARK_FAILED,
|
||||
}));
|
||||
});
|
||||
|
||||
it('Should delete bookmark and update model state', async () => {
|
||||
axiosMock.onDelete(deleteBookmarkURL).reply(201);
|
||||
|
||||
await executeThunk(thunks.removeBookmark(unitId), store.dispatch);
|
||||
await executeThunk(removeBookmark(unitId), store.dispatch);
|
||||
|
||||
expect(store.getState().models.units[unitId]).toEqual(expect.objectContaining({
|
||||
bookmarked: false,
|
||||
bookmarkedUpdateState: 'loaded',
|
||||
bookmarkedUpdateState: BOOKMARK_LOADED,
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,10 @@ import {
|
||||
} from './api';
|
||||
import { updateModel } from '../../../../generic/model-store';
|
||||
|
||||
export const BOOKMARK_LOADING = 'loading';
|
||||
export const BOOKMARK_LOADED = 'loaded';
|
||||
export const BOOKMARK_FAILED = 'failed';
|
||||
|
||||
export function addBookmark(unitId) {
|
||||
return async (dispatch) => {
|
||||
// Optimistically update the bookmarked flag.
|
||||
@@ -13,7 +17,7 @@ export function addBookmark(unitId) {
|
||||
model: {
|
||||
id: unitId,
|
||||
bookmarked: true,
|
||||
bookmarkedUpdateState: 'loading',
|
||||
bookmarkedUpdateState: BOOKMARK_LOADING,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -24,7 +28,7 @@ export function addBookmark(unitId) {
|
||||
model: {
|
||||
id: unitId,
|
||||
bookmarked: true,
|
||||
bookmarkedUpdateState: 'loaded',
|
||||
bookmarkedUpdateState: BOOKMARK_LOADED,
|
||||
},
|
||||
}));
|
||||
} catch (error) {
|
||||
@@ -34,7 +38,7 @@ export function addBookmark(unitId) {
|
||||
model: {
|
||||
id: unitId,
|
||||
bookmarked: false,
|
||||
bookmarkedUpdateState: 'failed',
|
||||
bookmarkedUpdateState: BOOKMARK_FAILED,
|
||||
},
|
||||
}));
|
||||
}
|
||||
@@ -49,7 +53,7 @@ export function removeBookmark(unitId) {
|
||||
model: {
|
||||
id: unitId,
|
||||
bookmarked: false,
|
||||
bookmarkedUpdateState: 'loading',
|
||||
bookmarkedUpdateState: BOOKMARK_LOADING,
|
||||
},
|
||||
}));
|
||||
try {
|
||||
@@ -59,7 +63,7 @@ export function removeBookmark(unitId) {
|
||||
model: {
|
||||
id: unitId,
|
||||
bookmarked: false,
|
||||
bookmarkedUpdateState: 'loaded',
|
||||
bookmarkedUpdateState: BOOKMARK_LOADED,
|
||||
},
|
||||
}));
|
||||
} catch (error) {
|
||||
@@ -69,7 +73,7 @@ export function removeBookmark(unitId) {
|
||||
model: {
|
||||
id: unitId,
|
||||
bookmarked: true,
|
||||
bookmarkedUpdateState: 'failed',
|
||||
bookmarkedUpdateState: BOOKMARK_FAILED,
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
export { default as BookmarkButton } from './BookmarkButton';
|
||||
export { default as BookmarkFilledIcon } from './BookmarkFilledIcon';
|
||||
export { default as BookmarkOutlineIcon } from './BookmarkFilledIcon';
|
||||
|
||||
export {
|
||||
BOOKMARK_LOADING,
|
||||
BOOKMARK_LOADED,
|
||||
BOOKMARK_FAILED,
|
||||
} from './data/thunks';
|
||||
|
||||
@@ -15,6 +15,7 @@ import CourseLicense from '../course-license';
|
||||
import messages from './messages';
|
||||
import { SequenceNavigation, UnitNavigation } from './sequence-navigation';
|
||||
import SequenceContent from './SequenceContent';
|
||||
import { SEQUENCE_LOADED, SEQUENCE_LOADING } from '../../data';
|
||||
|
||||
function Sequence({
|
||||
unitId,
|
||||
@@ -73,7 +74,7 @@ function Sequence({
|
||||
const { add, remove } = useContext(UserMessagesContext);
|
||||
useEffect(() => {
|
||||
let id = null;
|
||||
if (sequenceStatus === 'loaded') {
|
||||
if (sequenceStatus === SEQUENCE_LOADED) {
|
||||
if (sequence.bannerText) {
|
||||
id = add({
|
||||
code: null,
|
||||
@@ -101,7 +102,7 @@ function Sequence({
|
||||
}
|
||||
}, [unit]);
|
||||
|
||||
if (sequenceStatus === 'loading') {
|
||||
if (sequenceStatus === SEQUENCE_LOADING) {
|
||||
if (!sequenceId) {
|
||||
return (<div> {intl.formatMessage(messages['learn.sequence.no.content'])} </div>);
|
||||
}
|
||||
@@ -114,7 +115,7 @@ function Sequence({
|
||||
|
||||
const gated = sequence && sequence.gatedContent !== undefined && sequence.gatedContent.gated;
|
||||
|
||||
if (sequenceStatus === 'loaded') {
|
||||
if (sequenceStatus === SEQUENCE_LOADED) {
|
||||
return (
|
||||
<div className="sequence-container">
|
||||
<div className="sequence">
|
||||
|
||||
@@ -20,11 +20,11 @@ describe('Sequence', () => {
|
||||
|
||||
beforeAll(async () => {
|
||||
const store = await initializeTestStore({ courseMetadata, unitBlocks });
|
||||
const { courseware } = store.getState();
|
||||
const { courseware, activeCourse } = store.getState();
|
||||
mockData = {
|
||||
unitId: unitBlocks[0].id,
|
||||
sequenceId: courseware.sequenceId,
|
||||
courseId: courseware.courseId,
|
||||
courseId: activeCourse.courseId,
|
||||
unitNavigationHandler: () => {},
|
||||
nextSequenceHandler: () => {},
|
||||
previousSequenceHandler: () => {},
|
||||
|
||||
@@ -8,10 +8,10 @@ describe('Sequence Content', () => {
|
||||
|
||||
beforeAll(async () => {
|
||||
store = await initializeTestStore();
|
||||
const { models, courseware } = store.getState();
|
||||
const { models, courseware, activeCourse } = store.getState();
|
||||
mockData = {
|
||||
gated: false,
|
||||
courseId: courseware.courseId,
|
||||
courseId: activeCourse.courseId,
|
||||
sequenceId: courseware.sequenceId,
|
||||
unitId: models.sequences[courseware.sequenceId].unitIds[0],
|
||||
unitLoadedHandler: () => {},
|
||||
|
||||
@@ -5,6 +5,7 @@ import React, {
|
||||
useState,
|
||||
useLayoutEffect,
|
||||
} from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import PropTypes from 'prop-types';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
@@ -12,6 +13,9 @@ import messages from './messages';
|
||||
import BookmarkButton from '../bookmark/BookmarkButton';
|
||||
import { useModel } from '../../../generic/model-store';
|
||||
import PageLoading from '../../../generic/PageLoading';
|
||||
import { resetDeadlines } from '../../../course-home/data/thunks';
|
||||
import { fetchCourse } from '../../data/thunks';
|
||||
import { BOOKMARK_LOADING } from '../bookmark';
|
||||
|
||||
const LockPaywall = React.lazy(() => import('./lock-paywall'));
|
||||
|
||||
@@ -64,6 +68,8 @@ function Unit({
|
||||
contentTypeGatingEnabled,
|
||||
} = course;
|
||||
|
||||
const dispatch = useDispatch();
|
||||
|
||||
// Do not remove this hook. See function description.
|
||||
useLoadBearingHook(id);
|
||||
|
||||
@@ -101,7 +107,7 @@ function Unit({
|
||||
<BookmarkButton
|
||||
unitId={unit.id}
|
||||
isBookmarked={unit.bookmarked}
|
||||
isProcessing={unit.bookmarkedUpdateState === 'loading'}
|
||||
isProcessing={unit.bookmarkedUpdateState === BOOKMARK_LOADING}
|
||||
/>
|
||||
{ contentTypeGatingEnabled && unit.graded && (
|
||||
<Suspense
|
||||
@@ -130,6 +136,13 @@ function Unit({
|
||||
height={iframeHeight}
|
||||
scrolling="no"
|
||||
referrerPolicy="origin"
|
||||
onLoad={() => {
|
||||
window.onmessage = function (e) {
|
||||
if (e.data === 'reset_dates') {
|
||||
dispatch(resetDeadlines(courseId, fetchCourse));
|
||||
}
|
||||
};
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,13 +5,14 @@ import classNames from 'classnames';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faChevronLeft, faChevronRight } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import { useModel } from '../../../../generic/model-store';
|
||||
|
||||
import UnitButton from './UnitButton';
|
||||
import SequenceNavigationTabs from './SequenceNavigationTabs';
|
||||
import { useSequenceNavigationMetadata } from './hooks';
|
||||
import { useModel } from '../../../../generic/model-store';
|
||||
import { LOADED } from '../../../data/slice';
|
||||
import { SEQUENCE_LOADED } from '../../../data';
|
||||
|
||||
export default function SequenceNavigation({
|
||||
unitId,
|
||||
@@ -24,7 +25,7 @@ export default function SequenceNavigation({
|
||||
const sequence = useModel('sequences', sequenceId);
|
||||
const { isFirstUnit, isLastUnit } = useSequenceNavigationMetadata(sequenceId, unitId);
|
||||
const sequenceStatus = useSelector(state => state.courseware.sequenceStatus);
|
||||
const isLocked = sequenceStatus === LOADED ? (
|
||||
const isLocked = sequenceStatus === SEQUENCE_LOADED ? (
|
||||
sequence.gatedContent !== undefined && sequence.gatedContent.gated
|
||||
) : undefined;
|
||||
|
||||
@@ -49,7 +50,7 @@ export default function SequenceNavigation({
|
||||
);
|
||||
};
|
||||
|
||||
return sequenceStatus === LOADED && (
|
||||
return sequenceStatus === SEQUENCE_LOADED && (
|
||||
<nav className={classNames('sequence-navigation', className)}>
|
||||
<Button className="previous-btn" onClick={previousSequenceHandler} disabled={isFirstUnit}>
|
||||
<FontAwesomeIcon icon={faChevronLeft} className="mr-2" size="sm" />
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import { useModel } from '../../../../generic/model-store';
|
||||
import { COURSE_LOADED } from '../../../../active-course';
|
||||
|
||||
import { sequenceIdsSelector } from '../../../data/selectors';
|
||||
|
||||
export function useSequenceNavigationMetadata(currentSequenceId, currentUnitId) {
|
||||
const sequenceIds = useSelector(sequenceIdsSelector);
|
||||
const sequence = useModel('sequences', currentSequenceId);
|
||||
const courseStatus = useSelector(state => state.courseware.courseStatus);
|
||||
const courseStatus = useSelector(state => state.activeCourse.courseStatus);
|
||||
|
||||
// If we don't know the sequence and unit yet, then assume no.
|
||||
if (courseStatus !== 'loaded' || !currentSequenceId || !currentUnitId) {
|
||||
if (courseStatus !== COURSE_LOADED || !currentSequenceId || !currentUnitId) {
|
||||
return { isFirstUnit: false, isLastUnit: false };
|
||||
}
|
||||
const isFirstSequence = sequenceIds.indexOf(currentSequenceId) === 0;
|
||||
|
||||
@@ -34,6 +34,7 @@ Factory.define('courseMetadata')
|
||||
},
|
||||
show_calculator: false,
|
||||
is_staff: false,
|
||||
original_user_is_staff: false,
|
||||
license: 'all-rights-reserved',
|
||||
can_load_courseware: {
|
||||
has_access: true,
|
||||
|
||||
@@ -126,6 +126,7 @@ function normalizeMetadata(metadata) {
|
||||
enrollmentMode: metadata.enrollment.mode,
|
||||
isEnrolled: metadata.enrollment.is_active,
|
||||
canLoadCourseware: camelCaseObject(metadata.can_load_courseware),
|
||||
originalUserIsStaff: metadata.original_user_is_staff,
|
||||
isStaff: metadata.is_staff,
|
||||
license: metadata.license,
|
||||
verifiedMode: camelCaseObject(metadata.verified_mode),
|
||||
|
||||
@@ -10,4 +10,9 @@ export {
|
||||
export {
|
||||
sequenceIdsSelector,
|
||||
} from './selectors';
|
||||
export { reducer } from './slice';
|
||||
export {
|
||||
reducer,
|
||||
SEQUENCE_LOADING,
|
||||
SEQUENCE_LOADED,
|
||||
SEQUENCE_FAILED,
|
||||
} from './slice';
|
||||
|
||||
@@ -11,6 +11,8 @@ import executeThunk from '../../utils';
|
||||
import buildSimpleCourseBlocks from './__factories__/courseBlocks.factory';
|
||||
import initializeMockApp from '../../setupTest';
|
||||
import initializeStore from '../../store';
|
||||
import { SEQUENCE_LOADING, SEQUENCE_LOADED, SEQUENCE_FAILED } from './slice';
|
||||
import { COURSE_LOADED, COURSE_FAILED, COURSE_DENIED } from '../../active-course';
|
||||
|
||||
const { loggingService } = initializeMockApp();
|
||||
|
||||
@@ -53,9 +55,9 @@ describe('Data layer integration tests', () => {
|
||||
await executeThunk(thunks.fetchCourse(courseId), store.dispatch);
|
||||
|
||||
expect(loggingService.logError).toHaveBeenCalled();
|
||||
expect(store.getState().courseware).toEqual(expect.objectContaining({
|
||||
expect(store.getState().activeCourse).toEqual(expect.objectContaining({
|
||||
courseId,
|
||||
courseStatus: 'failed',
|
||||
courseStatus: COURSE_FAILED,
|
||||
}));
|
||||
});
|
||||
|
||||
@@ -78,7 +80,7 @@ describe('Data layer integration tests', () => {
|
||||
|
||||
const state = store.getState();
|
||||
|
||||
expect(state.courseware.courseStatus).toEqual('denied');
|
||||
expect(state.activeCourse.courseStatus).toEqual(COURSE_DENIED);
|
||||
|
||||
// check that at least one key camel cased, thus course data normalized
|
||||
expect(state.models.courses[forbiddenCourseMetadata.id].canLoadCourseware).not.toBeUndefined();
|
||||
@@ -92,9 +94,9 @@ describe('Data layer integration tests', () => {
|
||||
|
||||
const state = store.getState();
|
||||
|
||||
expect(state.courseware.courseStatus).toEqual('loaded');
|
||||
expect(state.courseware.courseId).toEqual(courseId);
|
||||
expect(state.courseware.sequenceStatus).toEqual('loading');
|
||||
expect(state.activeCourse.courseStatus).toEqual(COURSE_LOADED);
|
||||
expect(state.activeCourse.courseId).toEqual(courseId);
|
||||
expect(state.courseware.sequenceStatus).toEqual(SEQUENCE_LOADING);
|
||||
expect(state.courseware.sequenceId).toEqual(null);
|
||||
|
||||
// check that at least one key camel cased, thus course data normalized
|
||||
@@ -109,7 +111,7 @@ describe('Data layer integration tests', () => {
|
||||
await executeThunk(thunks.fetchSequence(sequenceId), store.dispatch);
|
||||
|
||||
expect(loggingService.logError).toHaveBeenCalled();
|
||||
expect(store.getState().courseware.sequenceStatus).toEqual('failed');
|
||||
expect(store.getState().courseware.sequenceStatus).toEqual(SEQUENCE_FAILED);
|
||||
});
|
||||
|
||||
it('Should fetch and normalize metadata, and then update existing models with sequence metadata', async () => {
|
||||
@@ -139,9 +141,9 @@ describe('Data layer integration tests', () => {
|
||||
// Update our state variable again.
|
||||
state = store.getState();
|
||||
|
||||
expect(state.courseware.courseStatus).toEqual('loaded');
|
||||
expect(state.courseware.courseId).toEqual(courseId);
|
||||
expect(state.courseware.sequenceStatus).toEqual('loading');
|
||||
expect(state.activeCourse.courseStatus).toEqual(COURSE_LOADED);
|
||||
expect(state.activeCourse.courseId).toEqual(courseId);
|
||||
expect(state.courseware.sequenceStatus).toEqual(SEQUENCE_LOADING);
|
||||
expect(state.courseware.sequenceId).toEqual(null);
|
||||
|
||||
await executeThunk(thunks.fetchSequence(sequenceId), store.dispatch);
|
||||
@@ -163,9 +165,9 @@ describe('Data layer integration tests', () => {
|
||||
}),
|
||||
});
|
||||
|
||||
expect(state.courseware.courseStatus).toEqual('loaded');
|
||||
expect(state.courseware.courseId).toEqual(courseId);
|
||||
expect(state.courseware.sequenceStatus).toEqual('loaded');
|
||||
expect(state.activeCourse.courseStatus).toEqual(COURSE_LOADED);
|
||||
expect(state.activeCourse.courseId).toEqual(courseId);
|
||||
expect(state.courseware.sequenceStatus).toEqual(SEQUENCE_LOADED);
|
||||
expect(state.courseware.sequenceId).toEqual(sequenceId);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
import { COURSE_LOADED } from '../../active-course';
|
||||
|
||||
export function sequenceIdsSelector(state) {
|
||||
if (state.courseware.courseStatus !== 'loaded') {
|
||||
if (state.activeCourse.courseStatus !== COURSE_LOADED) {
|
||||
return [];
|
||||
}
|
||||
const { sectionIds = [] } = state.models.courses[state.courseware.courseId];
|
||||
const { sectionIds = [] } = state.models.courses[state.activeCourse.courseId];
|
||||
|
||||
const sequenceIds = sectionIds
|
||||
.flatMap(sectionId => state.models.sections[sectionId].sequenceIds);
|
||||
|
||||
@@ -1,56 +1,33 @@
|
||||
/* eslint-disable no-param-reassign */
|
||||
import { createSlice } from '@reduxjs/toolkit';
|
||||
|
||||
export const LOADING = 'loading';
|
||||
export const LOADED = 'loaded';
|
||||
export const FAILED = 'failed';
|
||||
export const DENIED = 'denied';
|
||||
export const SEQUENCE_LOADING = 'loading';
|
||||
export const SEQUENCE_LOADED = 'loaded';
|
||||
export const SEQUENCE_FAILED = 'failed';
|
||||
|
||||
const slice = createSlice({
|
||||
name: 'courseware',
|
||||
initialState: {
|
||||
courseStatus: 'loading',
|
||||
courseId: null,
|
||||
sequenceStatus: 'loading',
|
||||
sequenceStatus: SEQUENCE_LOADING,
|
||||
sequenceId: null,
|
||||
},
|
||||
reducers: {
|
||||
fetchCourseRequest: (state, { payload }) => {
|
||||
state.courseId = payload.courseId;
|
||||
state.courseStatus = LOADING;
|
||||
},
|
||||
fetchCourseSuccess: (state, { payload }) => {
|
||||
state.courseId = payload.courseId;
|
||||
state.courseStatus = LOADED;
|
||||
},
|
||||
fetchCourseFailure: (state, { payload }) => {
|
||||
state.courseId = payload.courseId;
|
||||
state.courseStatus = FAILED;
|
||||
},
|
||||
fetchCourseDenied: (state, { payload }) => {
|
||||
state.courseId = payload.courseId;
|
||||
state.courseStatus = DENIED;
|
||||
},
|
||||
fetchSequenceRequest: (state, { payload }) => {
|
||||
state.sequenceId = payload.sequenceId;
|
||||
state.sequenceStatus = LOADING;
|
||||
state.sequenceStatus = SEQUENCE_LOADING;
|
||||
},
|
||||
fetchSequenceSuccess: (state, { payload }) => {
|
||||
state.sequenceId = payload.sequenceId;
|
||||
state.sequenceStatus = LOADED;
|
||||
state.sequenceStatus = SEQUENCE_LOADED;
|
||||
},
|
||||
fetchSequenceFailure: (state, { payload }) => {
|
||||
state.sequenceId = payload.sequenceId;
|
||||
state.sequenceStatus = FAILED;
|
||||
state.sequenceStatus = SEQUENCE_FAILED;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const {
|
||||
fetchCourseRequest,
|
||||
fetchCourseSuccess,
|
||||
fetchCourseFailure,
|
||||
fetchCourseDenied,
|
||||
fetchSequenceRequest,
|
||||
fetchSequenceSuccess,
|
||||
fetchSequenceFailure,
|
||||
|
||||
@@ -14,11 +14,15 @@ import {
|
||||
fetchCourseSuccess,
|
||||
fetchCourseFailure,
|
||||
fetchCourseDenied,
|
||||
} from '../../active-course';
|
||||
import {
|
||||
fetchSequenceRequest,
|
||||
fetchSequenceSuccess,
|
||||
fetchSequenceFailure,
|
||||
} from './slice';
|
||||
|
||||
const FULFILLED = 'fulfilled';
|
||||
|
||||
export function fetchCourse(courseId) {
|
||||
return async (dispatch) => {
|
||||
dispatch(fetchCourseRequest({ courseId }));
|
||||
@@ -26,14 +30,17 @@ export function fetchCourse(courseId) {
|
||||
getCourseMetadata(courseId),
|
||||
getCourseBlocks(courseId),
|
||||
]).then(([courseMetadataResult, courseBlocksResult]) => {
|
||||
if (courseMetadataResult.status === 'fulfilled') {
|
||||
const fetchedMetadata = courseMetadataResult.status === FULFILLED;
|
||||
const fetchedBlocks = courseBlocksResult.status === FULFILLED;
|
||||
|
||||
if (fetchedMetadata) {
|
||||
dispatch(addModel({
|
||||
modelType: 'courses',
|
||||
model: courseMetadataResult.value,
|
||||
}));
|
||||
}
|
||||
|
||||
if (courseBlocksResult.status === 'fulfilled') {
|
||||
if (fetchedBlocks) {
|
||||
const {
|
||||
courses, sections, sequences, units,
|
||||
} = courseBlocksResult.value;
|
||||
@@ -58,9 +65,6 @@ export function fetchCourse(courseId) {
|
||||
}));
|
||||
}
|
||||
|
||||
const fetchedMetadata = courseMetadataResult.status === 'fulfilled';
|
||||
const fetchedBlocks = courseBlocksResult.status === 'fulfilled';
|
||||
|
||||
// Log errors for each request if needed. Course block failures may occur
|
||||
// even if the course metadata request is successful
|
||||
if (!fetchedBlocks) {
|
||||
@@ -72,7 +76,7 @@ export function fetchCourse(courseId) {
|
||||
|
||||
if (fetchedMetadata) {
|
||||
if (courseMetadataResult.value.canLoadCourseware.hasAccess && fetchedBlocks) {
|
||||
// User has access
|
||||
// User has access - we dispatch this at the end now that all the data is loaded.
|
||||
dispatch(fetchCourseSuccess({ courseId }));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1 +1,3 @@
|
||||
export { default } from './CoursewareContainer';
|
||||
export { default as CoursewareRedirect } from './CoursewareRedirect';
|
||||
export { reducer } from './data/slice';
|
||||
|
||||
@@ -19,8 +19,7 @@ import { UserMessagesProvider } from './generic/user-messages';
|
||||
import './index.scss';
|
||||
import './assets/favicon.ico';
|
||||
import OutlineTab from './course-home/outline-tab';
|
||||
import CoursewareContainer from './courseware';
|
||||
import CoursewareRedirect from './CoursewareRedirect';
|
||||
import CoursewareContainer, { CoursewareRedirect } from './courseware';
|
||||
import DatesTab from './course-home/dates-tab';
|
||||
import ProgressTab from './course-home/progress-tab/ProgressTab';
|
||||
import { TabContainer } from './tab-page';
|
||||
|
||||
@@ -51,23 +51,21 @@ export default function InstructorToolbar(props) {
|
||||
return (
|
||||
<div className="bg-primary text-light">
|
||||
<div className="container-fluid py-3 d-md-flex justify-content-end align-items-center">
|
||||
<div className="flex-grow-1">
|
||||
<div className="align-items-center flex-grow-1 d-md-flex mx-1 my-1">
|
||||
<MasqueradeWidget courseId={courseId} />
|
||||
</div>
|
||||
{urlLms && (
|
||||
<div className="flex-shrink-0">
|
||||
<div className="flex-shrink-0 mx-1 my-1">
|
||||
<a className="btn d-block btn-outline-light" href={urlLms}>View in the existing experience</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{urlStudio && (
|
||||
<div className="flex-shrink-0">
|
||||
<div className="flex-shrink-0 mx-1 my-1">
|
||||
<a className="btn d-block btn-outline-light" href={urlStudio}>View in Studio</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{urlInsights && (
|
||||
<div className="flex-shrink-0">
|
||||
<div className="flex-shrink-0 mx-1 my-1">
|
||||
<a className="btn d-block btn-outline-light" href={urlInsights}>View in Insights</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import React, {
|
||||
Component,
|
||||
} from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { Input } from '@edx/paragon';
|
||||
|
||||
import messages from './messages';
|
||||
|
||||
class MasqueradeUserNameInput extends Component {
|
||||
onError(...args) {
|
||||
return this.props.onError(...args);
|
||||
}
|
||||
|
||||
onKeyPress(event) {
|
||||
if (event.key === 'Enter') {
|
||||
return this.onSubmit(event);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
onSubmit(event) {
|
||||
const payload = {
|
||||
role: 'student',
|
||||
user_name: event.target.value,
|
||||
};
|
||||
this.props.onSubmit(payload).then((data) => {
|
||||
if (data && data.success) {
|
||||
global.location.reload();
|
||||
} else {
|
||||
const error = (data && data.error) || '';
|
||||
this.onError(error);
|
||||
}
|
||||
}).catch(() => {
|
||||
const message = this.props.intl.formatMessage(messages['userName.error.generic']);
|
||||
this.onError(message);
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<Input
|
||||
autoFocus
|
||||
className="flex-shrink-1"
|
||||
defaultValue=""
|
||||
label={this.props.intl.formatMessage(messages['userName.input.label'])}
|
||||
onKeyPress={(event) => this.onKeyPress(event)}
|
||||
placeholder={this.props.intl.formatMessage(messages['userName.input.placeholder'])}
|
||||
type="text"
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
MasqueradeUserNameInput.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
onError: PropTypes.func.isRequired,
|
||||
onSubmit: PropTypes.func.isRequired,
|
||||
};
|
||||
export default injectIntl(MasqueradeUserNameInput);
|
||||
@@ -2,10 +2,21 @@ import React, {
|
||||
Component,
|
||||
} from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { Dropdown } from '@edx/paragon';
|
||||
|
||||
import {
|
||||
ALERT_TYPES,
|
||||
UserMessagesContext,
|
||||
} from '../../generic/user-messages';
|
||||
|
||||
import MasqueradeUserNameInput from './MasqueradeUserNameInput';
|
||||
import MasqueradeWidgetOption from './MasqueradeWidgetOption';
|
||||
import { getMasqueradeOptions } from './data/api';
|
||||
import {
|
||||
getMasqueradeOptions,
|
||||
postMasqueradeOptions,
|
||||
} from './data/api';
|
||||
import messages from './messages';
|
||||
|
||||
class MasqueradeWidget extends Component {
|
||||
constructor(props) {
|
||||
@@ -13,33 +24,100 @@ class MasqueradeWidget extends Component {
|
||||
this.courseId = props.courseId;
|
||||
this.state = {
|
||||
options: [],
|
||||
shouldShowUserNameInput: false,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
getMasqueradeOptions(this.courseId).then((data) => {
|
||||
if (data.success) {
|
||||
const options = this.parseAvailableOptions(data);
|
||||
this.setState({
|
||||
options,
|
||||
});
|
||||
this.onSuccess(data);
|
||||
} else {
|
||||
// This was explicitly denied by the backend;
|
||||
// assume it's disabled/unavailable.
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('Unable to get masquerade options', data);
|
||||
this.onError('Unable to get masquerade options');
|
||||
}
|
||||
}).catch((response) => {
|
||||
// There's not much we can do to recover;
|
||||
// if we can't fetch masquerade options,
|
||||
// assume it's disabled/unavailable.
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Unable to get masquerade options', response);
|
||||
});
|
||||
}
|
||||
|
||||
parseAvailableOptions(payload) {
|
||||
const data = payload || {};
|
||||
onError(message) {
|
||||
if (message) {
|
||||
this.errorAlertId = this.context.add({
|
||||
text: message,
|
||||
topic: 'course',
|
||||
type: ALERT_TYPES.ERROR,
|
||||
dismissible: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async onSubmit(payload) {
|
||||
this.context.remove(this.errorAlertId);
|
||||
const options = await postMasqueradeOptions(this.courseId, payload);
|
||||
return options;
|
||||
}
|
||||
|
||||
onSuccess(data) {
|
||||
const options = this.parseAvailableOptions(data);
|
||||
this.setState({
|
||||
options,
|
||||
});
|
||||
const active = data.active || {};
|
||||
const message = this.getStatusMessage(active);
|
||||
if (message) {
|
||||
this.context.add({
|
||||
text: message,
|
||||
topic: 'course',
|
||||
type: ALERT_TYPES.INFO,
|
||||
dismissible: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
getStatusMessage(active) {
|
||||
const {
|
||||
groupName,
|
||||
} = active;
|
||||
let message = '';
|
||||
if (active.userName) {
|
||||
message = this.props.intl.formatMessage(messages['status.userName'], {
|
||||
userName: active.userName,
|
||||
});
|
||||
} else if (groupName) {
|
||||
message = this.props.intl.formatMessage(messages['status.groupName'], {
|
||||
groupName,
|
||||
});
|
||||
} else if (active.role === 'student') {
|
||||
message = this.props.intl.formatMessage(messages['status.learner']);
|
||||
}
|
||||
return message;
|
||||
}
|
||||
|
||||
toggle(show) {
|
||||
let shouldShow;
|
||||
if (show === undefined) {
|
||||
shouldShow = !this.state.shouldShowUserNameInput;
|
||||
} else {
|
||||
shouldShow = show;
|
||||
}
|
||||
this.setState({
|
||||
shouldShowUserNameInput: shouldShow,
|
||||
});
|
||||
}
|
||||
|
||||
parseAvailableOptions(postData) {
|
||||
const data = postData || {};
|
||||
const active = data.active || {};
|
||||
const available = data.available || [];
|
||||
const options = available.map((group) => (
|
||||
<MasqueradeWidgetOption
|
||||
courseId={this.courseId}
|
||||
groupId={group.groupId}
|
||||
groupName={group.name}
|
||||
key={group.name}
|
||||
@@ -47,6 +125,9 @@ class MasqueradeWidget extends Component {
|
||||
selected={active}
|
||||
userName={group.userName}
|
||||
userPartitionId={group.userPartitionId}
|
||||
userNameInput={this.userNameInput}
|
||||
userNameInputToggle={(...args) => this.toggle(...args)}
|
||||
onSubmit={(payload) => this.onSubmit(payload)}
|
||||
/>
|
||||
));
|
||||
return options;
|
||||
@@ -57,18 +138,34 @@ class MasqueradeWidget extends Component {
|
||||
options,
|
||||
} = this.state;
|
||||
return (
|
||||
<Dropdown>
|
||||
<Dropdown.Button>
|
||||
View this course as
|
||||
</Dropdown.Button>
|
||||
<Dropdown.Menu>
|
||||
{options}
|
||||
</Dropdown.Menu>
|
||||
</Dropdown>
|
||||
<>
|
||||
<Dropdown
|
||||
className="flex-shrink-1 mx-1 my-1"
|
||||
style={{ textAlign: 'center' }}
|
||||
>
|
||||
<Dropdown.Button>
|
||||
View this course as
|
||||
</Dropdown.Button>
|
||||
<Dropdown.Menu>
|
||||
{options}
|
||||
</Dropdown.Menu>
|
||||
</Dropdown>
|
||||
{this.state.shouldShowUserNameInput && (
|
||||
<MasqueradeUserNameInput
|
||||
className="flex-shrink-0 mx-1 my-1"
|
||||
label="test"
|
||||
onError={(errorMessage) => this.onError(errorMessage)}
|
||||
onSubmit={(payload) => this.onSubmit(payload)}
|
||||
ref={(input) => { this.userNameInput = input; }}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
MasqueradeWidget.propTypes = {
|
||||
courseId: PropTypes.string.isRequired,
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
export default MasqueradeWidget;
|
||||
MasqueradeWidget.contextType = UserMessagesContext;
|
||||
export default injectIntl(MasqueradeWidget);
|
||||
|
||||
@@ -4,18 +4,28 @@ import React, {
|
||||
import PropTypes from 'prop-types';
|
||||
import { Dropdown } from '@edx/paragon';
|
||||
|
||||
import { postMasqueradeOptions } from './data/api';
|
||||
|
||||
class MasqueradeWidgetOption extends Component {
|
||||
handleClick() {
|
||||
onClick(event) {
|
||||
// TODO: Remove this hack when we upgrade Paragon
|
||||
// Note: The current version of Paragon does _not_ close dropdown components
|
||||
// automatically (or easily programmatically) when you click on an item.
|
||||
// We can simulate this behavior by programmatically clicking the
|
||||
// toggle button on behalf of the user.
|
||||
// The newest version of Paragon already contains this behavior,
|
||||
// so we can remove this when we upgrade to that point.
|
||||
event.target.parentNode.parentNode.click();
|
||||
const {
|
||||
courseId,
|
||||
groupId,
|
||||
role,
|
||||
userName,
|
||||
userPartitionId,
|
||||
userNameInputToggle,
|
||||
} = this.props;
|
||||
const payload = {};
|
||||
if (userName || userName === '') {
|
||||
userNameInputToggle(true);
|
||||
return false;
|
||||
}
|
||||
if (role) {
|
||||
payload.role = role;
|
||||
}
|
||||
@@ -23,21 +33,24 @@ class MasqueradeWidgetOption extends Component {
|
||||
payload.group_id = parseInt(groupId, 10);
|
||||
payload.user_partition_id = parseInt(userPartitionId, 10);
|
||||
}
|
||||
if (userName) {
|
||||
payload.user_name = userName;
|
||||
}
|
||||
postMasqueradeOptions(courseId, payload).then(() => {
|
||||
this.props.onSubmit(payload).then(() => {
|
||||
global.location.reload();
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
isSelected() {
|
||||
const selected = this.props.selected || {};
|
||||
const isEqual = (
|
||||
selected.userPartitionId === (this.props.userPartitionId || null)
|
||||
&& selected.groupId === (this.props.groupId || null)
|
||||
&& selected.role === this.props.role
|
||||
);
|
||||
/* eslint-disable arrow-body-style */
|
||||
const isEqual = [
|
||||
'groupId',
|
||||
'role',
|
||||
'userName',
|
||||
'userPartitionId',
|
||||
].reduce((accumulator, currentValue) => {
|
||||
return accumulator && (
|
||||
this.props[currentValue] === this.props.selected[currentValue]
|
||||
);
|
||||
}, true);
|
||||
return isEqual;
|
||||
}
|
||||
|
||||
@@ -57,7 +70,7 @@ class MasqueradeWidgetOption extends Component {
|
||||
<Dropdown.Item
|
||||
className={className}
|
||||
href="#"
|
||||
onClick={(event) => this.handleClick(event)}
|
||||
onClick={(event) => this.onClick(event)}
|
||||
>
|
||||
{groupName}
|
||||
</Dropdown.Item>
|
||||
@@ -65,9 +78,9 @@ class MasqueradeWidgetOption extends Component {
|
||||
}
|
||||
}
|
||||
MasqueradeWidgetOption.propTypes = {
|
||||
courseId: PropTypes.string.isRequired,
|
||||
groupId: PropTypes.number,
|
||||
groupName: PropTypes.string.isRequired,
|
||||
onSubmit: PropTypes.func.isRequired,
|
||||
role: PropTypes.string,
|
||||
selected: PropTypes.shape({
|
||||
courseKey: PropTypes.string.isRequired,
|
||||
@@ -77,6 +90,7 @@ MasqueradeWidgetOption.propTypes = {
|
||||
userPartitionId: PropTypes.number,
|
||||
}),
|
||||
userName: PropTypes.string,
|
||||
userNameInputToggle: PropTypes.func.isRequired,
|
||||
userPartitionId: PropTypes.number,
|
||||
};
|
||||
MasqueradeWidgetOption.defaultProps = {
|
||||
|
||||
@@ -7,8 +7,8 @@ export async function getMasqueradeOptions(courseId) {
|
||||
return camelCaseObject(data);
|
||||
}
|
||||
|
||||
export async function postMasqueradeOptions(courseId, data) {
|
||||
export async function postMasqueradeOptions(courseId, payload) {
|
||||
const url = new URL(`${getConfig().LMS_BASE_URL}/courses/${courseId}/masquerade`);
|
||||
const { response } = await getAuthenticatedHttpClient().post(url.href, data);
|
||||
return camelCaseObject(response);
|
||||
const { data } = await getAuthenticatedHttpClient().post(url.href, payload);
|
||||
return camelCaseObject(data);
|
||||
}
|
||||
|
||||
36
src/instructor-toolbar/masquerade-widget/messages.js
Normal file
36
src/instructor-toolbar/masquerade-widget/messages.js
Normal file
@@ -0,0 +1,36 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
'status.groupName': {
|
||||
id: 'masquerade-widget.status.groupName',
|
||||
defaultMessage: 'You are masquerading as a learner in the {groupName} group.',
|
||||
description: 'Message when masquerading as a generic user in a specific track',
|
||||
},
|
||||
'status.learner': {
|
||||
id: 'masquerade-widget.status.learner',
|
||||
defaultMessage: 'You are masquerading as a learner.',
|
||||
description: 'Message when masquerading as a specific user',
|
||||
},
|
||||
'status.userName': {
|
||||
id: 'masquerade-widget.status.userName',
|
||||
defaultMessage: 'You are masquerading as the following user: {userName}',
|
||||
description: 'Message when masquerading as a specific user',
|
||||
},
|
||||
'userName.input.label': {
|
||||
id: 'masquerade-widget.userName.input.label',
|
||||
defaultMessage: 'Masquerade as this user',
|
||||
description: 'Label for the masquerade user input',
|
||||
},
|
||||
'userName.error.generic': {
|
||||
id: 'masquerade-widget.userName.error.generic',
|
||||
defaultMessage: 'An error has occurred; please try again.',
|
||||
description: 'Message shown after a general error when attempting to masquerade',
|
||||
},
|
||||
'userName.input.placeholder': {
|
||||
id: 'masquerade-widget.userName.input.placeholder',
|
||||
defaultMessage: 'username or email',
|
||||
description: 'Placeholder text to prompt for a user to masquerade as',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
@@ -14,7 +14,7 @@ import { configureStore } from '@reduxjs/toolkit';
|
||||
import { IntlProvider } from 'react-intl';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import AppProvider from '@edx/frontend-platform/react/AppProvider';
|
||||
import { reducer as courseHomeReducer } from './course-home/data';
|
||||
import { reducer as activeCourseReducer } from './active-course';
|
||||
import { reducer as coursewareReducer } from './courseware/data/slice';
|
||||
import { reducer as modelsReducer } from './generic/model-store';
|
||||
import { UserMessagesProvider } from './generic/user-messages';
|
||||
@@ -81,8 +81,8 @@ export async function initializeTestStore(options = {}, overrideStore = true) {
|
||||
const store = configureStore({
|
||||
reducer: {
|
||||
models: modelsReducer,
|
||||
activeCourse: activeCourseReducer,
|
||||
courseware: coursewareReducer,
|
||||
courseHome: courseHomeReducer,
|
||||
},
|
||||
});
|
||||
if (overrideStore) {
|
||||
|
||||
11
src/store.js
11
src/store.js
@@ -1,14 +1,17 @@
|
||||
import { configureStore } from '@reduxjs/toolkit';
|
||||
import { reducer as courseHomeReducer } from './course-home/data';
|
||||
import { reducer as coursewareReducer } from './courseware/data/slice';
|
||||
|
||||
import { reducer as activeCourseReducer } from './active-course';
|
||||
import { reducer as courseHomeReducer } from './course-home';
|
||||
import { reducer as coursewareReducer } from './courseware';
|
||||
import { reducer as modelsReducer } from './generic/model-store';
|
||||
|
||||
export default function initializeStore() {
|
||||
return configureStore({
|
||||
reducer: {
|
||||
models: modelsReducer,
|
||||
courseware: coursewareReducer,
|
||||
activeCourse: activeCourseReducer,
|
||||
courseHome: courseHomeReducer,
|
||||
courseware: coursewareReducer,
|
||||
models: modelsReducer,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ function LoadedTabPage({
|
||||
unitId,
|
||||
}) {
|
||||
const {
|
||||
isStaff,
|
||||
originalUserIsStaff,
|
||||
number,
|
||||
org,
|
||||
tabs,
|
||||
@@ -26,7 +26,7 @@ function LoadedTabPage({
|
||||
courseNumber={number}
|
||||
courseTitle={title}
|
||||
/>
|
||||
{isStaff && (
|
||||
{originalUserIsStaff && (
|
||||
<InstructorToolbar
|
||||
courseId={courseId}
|
||||
unitId={unitId}
|
||||
|
||||
@@ -24,7 +24,7 @@ export default function TabContainer(props) {
|
||||
const {
|
||||
courseId,
|
||||
courseStatus,
|
||||
} = useSelector(state => state.courseHome);
|
||||
} = useSelector(state => state.activeCourse);
|
||||
|
||||
return (
|
||||
<TabPage
|
||||
|
||||
@@ -1,19 +1,29 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
import { Header } from '../course-header';
|
||||
import PageLoading from '../generic/PageLoading';
|
||||
|
||||
import messages from './messages';
|
||||
import LoadedTabPage from './LoadedTabPage';
|
||||
import LearningToast from '../toast/LearningToast';
|
||||
import { toggleResetDatesToast } from '../course-home/data/slice';
|
||||
import { COURSE_LOADED, COURSE_LOADING } from '../active-course';
|
||||
|
||||
function TabPage({
|
||||
intl,
|
||||
courseStatus,
|
||||
...passthroughProps
|
||||
}) {
|
||||
if (courseStatus === 'loading') {
|
||||
const courseId = useSelector(state => state.courseHome.courseId || state.courseware.courseId);
|
||||
const {
|
||||
displayResetDatesToast,
|
||||
} = useSelector(state => state.courseHome);
|
||||
const dispatch = useDispatch();
|
||||
|
||||
if (courseStatus === COURSE_LOADING) {
|
||||
return (
|
||||
<>
|
||||
<Header />
|
||||
@@ -24,9 +34,18 @@ function TabPage({
|
||||
);
|
||||
}
|
||||
|
||||
if (courseStatus === 'loaded') {
|
||||
if (courseStatus === COURSE_LOADED) {
|
||||
return (
|
||||
<LoadedTabPage {...passthroughProps} />
|
||||
<>
|
||||
<LearningToast
|
||||
body={messages.datesResetSuccessBody}
|
||||
header={messages.datesResetSuccessHeader}
|
||||
link={`/course/${courseId}/dates`}
|
||||
onClose={() => dispatch(toggleResetDatesToast({ displayResetDatesToast: false }))}
|
||||
show={displayResetDatesToast}
|
||||
/>
|
||||
<LoadedTabPage {...passthroughProps} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,14 @@ const messages = defineMessages({
|
||||
id: 'learning.loading',
|
||||
defaultMessage: 'Loading course page…',
|
||||
},
|
||||
datesResetSuccessBody: {
|
||||
id: 'learning.datesResetSuccessBody',
|
||||
defaultMessage: 'View all dates',
|
||||
},
|
||||
datesResetSuccessHeader: {
|
||||
id: 'learning.datesResetSuccessHeader',
|
||||
defaultMessage: 'Your due dates have been successfully shifted to help you stay on track.',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
|
||||
57
src/toast/LearningToast.jsx
Normal file
57
src/toast/LearningToast.jsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { Toast } from '@edx/paragon';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import './LearningToast.scss';
|
||||
|
||||
function LearningToast({
|
||||
intl,
|
||||
body,
|
||||
header,
|
||||
link,
|
||||
onClose,
|
||||
show,
|
||||
}) {
|
||||
return (
|
||||
<Toast
|
||||
onClose={onClose}
|
||||
show={show}
|
||||
delay={3000}
|
||||
autohide
|
||||
style={{
|
||||
boxShadow: 'none',
|
||||
position: 'fixed',
|
||||
bottom: '1rem',
|
||||
left: '1rem',
|
||||
zIndex: 2,
|
||||
}}
|
||||
>
|
||||
<Toast.Header className="bg-gray-700 border-bottom-0 text-light">
|
||||
<div className="mr-auto">{intl.formatMessage(header)}</div>
|
||||
</Toast.Header>
|
||||
<Toast.Body className="bg-gray-700 text-light">
|
||||
<Link className="text-light" to={link}>{intl.formatMessage(body)}</Link>
|
||||
</Toast.Body>
|
||||
</Toast>
|
||||
);
|
||||
}
|
||||
|
||||
LearningToast.propTypes = {
|
||||
body: PropTypes.shape({
|
||||
possible: PropTypes.number,
|
||||
earned: PropTypes.number,
|
||||
graded: PropTypes.bool,
|
||||
}).isRequired,
|
||||
header: PropTypes.shape({
|
||||
id: PropTypes.string,
|
||||
defaultMessage: PropTypes.string,
|
||||
}).isRequired,
|
||||
intl: intlShape.isRequired,
|
||||
link: PropTypes.string.isRequired,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
show: PropTypes.bool.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(LearningToast);
|
||||
19
src/toast/LearningToast.scss
Normal file
19
src/toast/LearningToast.scss
Normal file
@@ -0,0 +1,19 @@
|
||||
.toast-header {
|
||||
div {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
button {
|
||||
align-self: flex-start;
|
||||
color: #FFFFFF;
|
||||
&:hover {
|
||||
color: #FFFFFF;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.toast-body {
|
||||
a {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user