diff --git a/package-lock.json b/package-lock.json index 0e3e486..a8655fd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4233,6 +4233,24 @@ "resolved": "https://registry.npmjs.org/@redux-beacon/segment/-/segment-1.1.0.tgz", "integrity": "sha512-NLRoP3Jfx5z99YX6TFFznwXIMjqjD6/qdMZIKFRgGO8NtMWrCruA8EeQYPJZUBnuOjw6RtOA1UdjbqyRmdhc/Q==" }, + "@reduxjs/toolkit": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-1.5.1.tgz", + "integrity": "sha512-PngZKuwVZsd+mimnmhiOQzoD0FiMjqVks6ituO1//Ft5UEX5Ca9of13NEjo//pU22Jk7z/mdXVsmDfgsig1osA==", + "requires": { + "immer": "^8.0.1", + "redux": "^4.0.0", + "redux-thunk": "^2.3.0", + "reselect": "^4.0.0" + }, + "dependencies": { + "immer": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/immer/-/immer-8.0.4.tgz", + "integrity": "sha512-jMfL18P+/6P6epANRvRk6q8t+3gGhqsJ9EuJ25AXE+9bNTYtssvzeYbEd0mXRYWCmmXSIbnlpz6vd6iJlmGGGQ==" + } + } + }, "@restart/context": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/@restart/context/-/context-2.1.4.tgz", @@ -6162,6 +6180,23 @@ "dev": true, "requires": { "util": "0.10.3" + }, + "dependencies": { + "inherits": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.1.tgz", + "integrity": "sha1-sX0I0ya0Qj5Wjv9xn5GwscvfafE=", + "dev": true + }, + "util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/util/-/util-0.10.3.tgz", + "integrity": "sha1-evsa/lCAUkZInj23/g7TeTNqwPk=", + "dev": true, + "requires": { + "inherits": "2.0.1" + } + } } }, "assert-ok": { @@ -6250,6 +6285,14 @@ "postcss-value-parser": "^4.1.0" } }, + "available-typed-arrays": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.2.tgz", + "integrity": "sha512-XWX3OX8Onv97LMk/ftVyBibpGwY5a8SmuxZPzeOxqmuEqUCOM9ZE+uIaD1VNJ5QnvU2UQusvmKbuM1FR8QWGfQ==", + "requires": { + "array-filter": "^1.0.0" + } + }, "aws-sign2": { "version": "0.7.0", "resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.7.0.tgz", @@ -11823,6 +11866,11 @@ "integrity": "sha1-gQaNKVqBQuwKxybG4iAMMPttXoA=", "dev": true }, + "foreach": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/foreach/-/foreach-2.0.5.tgz", + "integrity": "sha1-C+4AUBiusmDQo6865ljdATbsG5k=" + }, "forever-agent": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz", @@ -13619,8 +13667,7 @@ "is-arguments": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.0.4.tgz", - "integrity": "sha512-xPh0Rmt8NE65sNzvyUmWgI1tz3mKq74lGA0mL8LYZcoIzKOzDh6HmrYm3d18k60nHerC8A9Km8kYu87zfSFnLA==", - "dev": true + "integrity": "sha512-xPh0Rmt8NE65sNzvyUmWgI1tz3mKq74lGA0mL8LYZcoIzKOzDh6HmrYm3d18k60nHerC8A9Km8kYu87zfSFnLA==" }, "is-arrayish": { "version": "0.2.1", @@ -13797,6 +13844,11 @@ "integrity": "sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==", "dev": true }, + "is-generator-function": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.0.9.tgz", + "integrity": "sha512-ZJ34p1uvIfptHCN7sFTjGibB9/oBg17sHqzDLfuwhvmN/qLVvIQXRQ8licZQ35WJ8KuEQt/etnnzQFI9C9Ue/A==" + }, "is-gif": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-gif/-/is-gif-3.0.0.tgz", @@ -14067,6 +14119,117 @@ "text-extensions": "^1.0.0" } }, + "is-typed-array": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.5.tgz", + "integrity": "sha512-S+GRDgJlR3PyEbsX/Fobd9cqpZBuvUS+8asRqYDMLCb2qMzt1oz5m5oxQCxOgUDxiWsOVNi4yaF+/uvdlHlYug==", + "requires": { + "available-typed-arrays": "^1.0.2", + "call-bind": "^1.0.2", + "es-abstract": "^1.18.0-next.2", + "foreach": "^2.0.5", + "has-symbols": "^1.0.1" + }, + "dependencies": { + "call-bind": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz", + "integrity": "sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==", + "requires": { + "function-bind": "^1.1.1", + "get-intrinsic": "^1.0.2" + } + }, + "es-abstract": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.18.0.tgz", + "integrity": "sha512-LJzK7MrQa8TS0ja2w3YNLzUgJCGPdPOV1yVvezjNnS89D+VR08+Szt2mz3YB2Dck/+w5tfIq/RoUAFqJJGM2yw==", + "requires": { + "call-bind": "^1.0.2", + "es-to-primitive": "^1.2.1", + "function-bind": "^1.1.1", + "get-intrinsic": "^1.1.1", + "has": "^1.0.3", + "has-symbols": "^1.0.2", + "is-callable": "^1.2.3", + "is-negative-zero": "^2.0.1", + "is-regex": "^1.1.2", + "is-string": "^1.0.5", + "object-inspect": "^1.9.0", + "object-keys": "^1.1.1", + "object.assign": "^4.1.2", + "string.prototype.trimend": "^1.0.4", + "string.prototype.trimstart": "^1.0.4", + "unbox-primitive": "^1.0.0" + }, + "dependencies": { + "has-symbols": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.2.tgz", + "integrity": "sha512-chXa79rL/UC2KlX17jo3vRGz0azaWEx5tGqZg5pO3NUyEJVB17dMruQlzCCOfUvElghKcm5194+BCRvi2Rv/Gw==" + } + } + }, + "get-intrinsic": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.1.1.tgz", + "integrity": "sha512-kWZrnVM42QCiEA2Ig1bG8zjoIMOgxWwYCEeNdwY6Tv/cOSeGpcoX4pXHfKUxNKVoArnrEr2e9srnAxxGIraS9Q==", + "requires": { + "function-bind": "^1.1.1", + "has": "^1.0.3", + "has-symbols": "^1.0.1" + } + }, + "is-callable": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.3.tgz", + "integrity": "sha512-J1DcMe8UYTBSrKezuIUTUwjXsho29693unXM2YhJUTR2txK/eG47bvNa/wipPFmZFgr/N6f1GA66dv0mEyTIyQ==" + }, + "is-regex": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.2.tgz", + "integrity": "sha512-axvdhb5pdhEVThqJzYXwMlVuZwC+FF2DpcOhTS+y/8jVq4trxyPgfcwIxIKiyeuLlSQYKkmUaPQJ8ZE4yNKXDg==", + "requires": { + "call-bind": "^1.0.2", + "has-symbols": "^1.0.1" + } + }, + "object-inspect": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.10.2.tgz", + "integrity": "sha512-gz58rdPpadwztRrPjZE9DZLOABUpTGdcANUgOwBFO1C+HZZhePoP83M65WGDmbpwFYJSWqavbl4SgDn4k8RYTA==" + }, + "object.assign": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.2.tgz", + "integrity": "sha512-ixT2L5THXsApyiUPYKmW+2EHpXXe5Ii3M+f4e+aJFAHao5amFRW6J0OO6c/LU8Be47utCx2GL89hxGB6XSmKuQ==", + "requires": { + "call-bind": "^1.0.0", + "define-properties": "^1.1.3", + "has-symbols": "^1.0.1", + "object-keys": "^1.1.1" + } + }, + "string.prototype.trimend": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.4.tgz", + "integrity": "sha512-y9xCjw1P23Awk8EvTpcyL2NIr1j7wJ39f+k6lvRnSMz+mz9CGz9NYPelDk42kOz6+ql8xjfK8oYzy3jAP5QU5A==", + "requires": { + "call-bind": "^1.0.2", + "define-properties": "^1.1.3" + } + }, + "string.prototype.trimstart": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.4.tgz", + "integrity": "sha512-jh6e984OBfvxS50tdY2nRZnoC5/mLFKOREQfw8t5yytkoUsJRNxvI/E39qu1sD0OtWI3OC0XgKSmcWwziwYuZw==", + "requires": { + "call-bind": "^1.0.2", + "define-properties": "^1.1.3" + } + } + } + }, "is-typedarray": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", @@ -16884,7 +17047,7 @@ "dependencies": { "query-string": { "version": "2.4.2", - "resolved": "https://registry.npmjs.org/query-string/-/query-string-2.4.2.tgz", + "resolved": "http://registry.npmjs.org/query-string/-/query-string-2.4.2.tgz", "integrity": "sha1-fbBmZCCAS6qSrp8miWKFWnYUPfs=", "requires": { "strict-uri-encode": "^1.0.0" @@ -25190,6 +25353,11 @@ "integrity": "sha1-kl0mAdOaxIXgkc8NpcbmlNw9yv8=", "dev": true }, + "reselect": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-4.0.0.tgz", + "integrity": "sha512-qUgANli03jjAyGlnbYVAV5vvnOmJnODyABz51RdBN7M4WaVu8mecZWgyQNkG8Yqe3KRGRt0l4K4B3XVEULC4CA==" + }, "resolve": { "version": "1.17.0", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.17.0.tgz", @@ -29061,20 +29229,16 @@ } }, "util": { - "version": "0.10.3", - "resolved": "https://registry.npmjs.org/util/-/util-0.10.3.tgz", - "integrity": "sha1-evsa/lCAUkZInj23/g7TeTNqwPk=", - "dev": true, + "version": "0.12.3", + "resolved": "https://registry.npmjs.org/util/-/util-0.12.3.tgz", + "integrity": "sha512-I8XkoQwE+fPQEhy9v012V+TSdH2kp9ts29i20TaaDUXsg7x/onePbhFJUExBfv/2ay1ZOp/Vsm3nDlmnFGSAog==", "requires": { - "inherits": "2.0.1" - }, - "dependencies": { - "inherits": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.1.tgz", - "integrity": "sha1-sX0I0ya0Qj5Wjv9xn5GwscvfafE=", - "dev": true - } + "inherits": "^2.0.3", + "is-arguments": "^1.0.4", + "is-generator-function": "^1.0.7", + "is-typed-array": "^1.1.3", + "safe-buffer": "^5.1.2", + "which-typed-array": "^1.1.2" } }, "util-deprecate": { @@ -30064,6 +30228,152 @@ "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.0.tgz", "integrity": "sha1-2e8H3Od7mQK4o6j6SzHD4/fm6Ho=" }, + "which-typed-array": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.4.tgz", + "integrity": "sha512-49E0SpUe90cjpoc7BOJwyPHRqSAd12c10Qm2amdEZrJPCY2NDxaW01zHITrem+rnETY3dwrbH3UUrUwagfCYDA==", + "requires": { + "available-typed-arrays": "^1.0.2", + "call-bind": "^1.0.0", + "es-abstract": "^1.18.0-next.1", + "foreach": "^2.0.5", + "function-bind": "^1.1.1", + "has-symbols": "^1.0.1", + "is-typed-array": "^1.1.3" + }, + "dependencies": { + "es-abstract": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.18.0.tgz", + "integrity": "sha512-LJzK7MrQa8TS0ja2w3YNLzUgJCGPdPOV1yVvezjNnS89D+VR08+Szt2mz3YB2Dck/+w5tfIq/RoUAFqJJGM2yw==", + "requires": { + "call-bind": "^1.0.2", + "es-to-primitive": "^1.2.1", + "function-bind": "^1.1.1", + "get-intrinsic": "^1.1.1", + "has": "^1.0.3", + "has-symbols": "^1.0.2", + "is-callable": "^1.2.3", + "is-negative-zero": "^2.0.1", + "is-regex": "^1.1.2", + "is-string": "^1.0.5", + "object-inspect": "^1.9.0", + "object-keys": "^1.1.1", + "object.assign": "^4.1.2", + "string.prototype.trimend": "^1.0.4", + "string.prototype.trimstart": "^1.0.4", + "unbox-primitive": "^1.0.0" + }, + "dependencies": { + "call-bind": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz", + "integrity": "sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==", + "requires": { + "function-bind": "^1.1.1", + "get-intrinsic": "^1.0.2" + } + }, + "has-symbols": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.2.tgz", + "integrity": "sha512-chXa79rL/UC2KlX17jo3vRGz0azaWEx5tGqZg5pO3NUyEJVB17dMruQlzCCOfUvElghKcm5194+BCRvi2Rv/Gw==" + } + } + }, + "get-intrinsic": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.1.1.tgz", + "integrity": "sha512-kWZrnVM42QCiEA2Ig1bG8zjoIMOgxWwYCEeNdwY6Tv/cOSeGpcoX4pXHfKUxNKVoArnrEr2e9srnAxxGIraS9Q==", + "requires": { + "function-bind": "^1.1.1", + "has": "^1.0.3", + "has-symbols": "^1.0.1" + } + }, + "is-callable": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.3.tgz", + "integrity": "sha512-J1DcMe8UYTBSrKezuIUTUwjXsho29693unXM2YhJUTR2txK/eG47bvNa/wipPFmZFgr/N6f1GA66dv0mEyTIyQ==" + }, + "is-regex": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.2.tgz", + "integrity": "sha512-axvdhb5pdhEVThqJzYXwMlVuZwC+FF2DpcOhTS+y/8jVq4trxyPgfcwIxIKiyeuLlSQYKkmUaPQJ8ZE4yNKXDg==", + "requires": { + "call-bind": "^1.0.2", + "has-symbols": "^1.0.1" + }, + "dependencies": { + "call-bind": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz", + "integrity": "sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==", + "requires": { + "function-bind": "^1.1.1", + "get-intrinsic": "^1.0.2" + } + } + } + }, + "object-inspect": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.10.2.tgz", + "integrity": "sha512-gz58rdPpadwztRrPjZE9DZLOABUpTGdcANUgOwBFO1C+HZZhePoP83M65WGDmbpwFYJSWqavbl4SgDn4k8RYTA==" + }, + "object.assign": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.2.tgz", + "integrity": "sha512-ixT2L5THXsApyiUPYKmW+2EHpXXe5Ii3M+f4e+aJFAHao5amFRW6J0OO6c/LU8Be47utCx2GL89hxGB6XSmKuQ==", + "requires": { + "call-bind": "^1.0.0", + "define-properties": "^1.1.3", + "has-symbols": "^1.0.1", + "object-keys": "^1.1.1" + } + }, + "string.prototype.trimend": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.4.tgz", + "integrity": "sha512-y9xCjw1P23Awk8EvTpcyL2NIr1j7wJ39f+k6lvRnSMz+mz9CGz9NYPelDk42kOz6+ql8xjfK8oYzy3jAP5QU5A==", + "requires": { + "call-bind": "^1.0.2", + "define-properties": "^1.1.3" + }, + "dependencies": { + "call-bind": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz", + "integrity": "sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==", + "requires": { + "function-bind": "^1.1.1", + "get-intrinsic": "^1.0.2" + } + } + } + }, + "string.prototype.trimstart": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.4.tgz", + "integrity": "sha512-jh6e984OBfvxS50tdY2nRZnoC5/mLFKOREQfw8t5yytkoUsJRNxvI/E39qu1sD0OtWI3OC0XgKSmcWwziwYuZw==", + "requires": { + "call-bind": "^1.0.2", + "define-properties": "^1.1.3" + }, + "dependencies": { + "call-bind": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz", + "integrity": "sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==", + "requires": { + "function-bind": "^1.1.1", + "get-intrinsic": "^1.0.2" + } + } + } + } + } + }, "wide-align": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.3.tgz", diff --git a/package.json b/package.json index 07fdb3a..989e61a 100755 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@edx/frontend-app-gradebook", - "version": "1.4.28", + "version": "1.4.29", "description": "edx editable gradebook-ui to manipulate grade overrides on subsections", "repository": { "type": "git", @@ -35,6 +35,7 @@ "@fortawesome/free-solid-svg-icons": "^5.11.2", "@fortawesome/react-fontawesome": "^0.1.5", "@redux-beacon/segment": "^1.0.0", + "@reduxjs/toolkit": "^1.5.1", "classnames": "^2.2.6", "core-js": "3.6.5", "email-prop-type": "^1.1.7", @@ -58,6 +59,7 @@ "redux-logger": "3.0.6", "redux-thunk": "2.3.0", "regenerator-runtime": "^0.13.7", + "util": "^0.12.3", "whatwg-fetch": "^2.0.4" }, "devDependencies": { diff --git a/src/components/Gradebook/BulkManagement.jsx b/src/components/Gradebook/BulkManagement.jsx index 361edbe..17621e4 100644 --- a/src/components/Gradebook/BulkManagement.jsx +++ b/src/components/Gradebook/BulkManagement.jsx @@ -12,8 +12,8 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faDownload } from '@fortawesome/free-solid-svg-icons'; import selectors from 'data/selectors'; -import { configuration } from '../../config'; -import { submitFileUploadFormData } from '../../data/actions/grades'; +import thunkActions from 'data/thunkActions'; +import { configuration } from 'config'; export class BulkManagement extends React.Component { constructor(props) { @@ -193,7 +193,7 @@ export const mapStateToProps = (state) => { }; export const mapDispatchToProps = { - submitFileUploadFormData, + submitFileUploadFormData: thunkActions.grades.submitFileUploadFormData, }; export default connect(mapStateToProps, mapDispatchToProps)(BulkManagement); diff --git a/src/components/Gradebook/BulkManagementControls.jsx b/src/components/Gradebook/BulkManagementControls.jsx index 033fd0a..b89f831 100644 --- a/src/components/Gradebook/BulkManagementControls.jsx +++ b/src/components/Gradebook/BulkManagementControls.jsx @@ -7,10 +7,7 @@ import { StatefulButton } from '@edx/paragon'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faDownload, faSpinner } from '@fortawesome/free-solid-svg-icons'; -import { - downloadBulkGradesReport, - downloadInterventionReport, -} from '../../data/actions/grades'; +import actions from 'data/actions'; export class BulkManagementControls extends React.Component { handleClickDownloadInterventions = () => { @@ -83,8 +80,8 @@ BulkManagementControls.propTypes = { export const mapStateToProps = () => ({ }); export const mapDispatchToProps = { - downloadBulkGradesReport, - downloadInterventionReport, + downloadBulkGradesReport: actions.grades.downloadReport.bulkGrades, + downloadInterventionReport: actions.grades.downloadReport.intervention, }; export default connect(mapStateToProps, mapDispatchToProps)(BulkManagementControls); diff --git a/src/components/Gradebook/EditModal.jsx b/src/components/Gradebook/EditModal.jsx index 59cffa6..57fc320 100644 --- a/src/components/Gradebook/EditModal.jsx +++ b/src/components/Gradebook/EditModal.jsx @@ -11,10 +11,8 @@ import { } from '@edx/paragon'; import selectors from 'data/selectors'; -import { - doneViewingAssignment, - updateGrades, -} from '../../data/actions/grades'; +import actions from 'data/actions'; +import thunkActions from 'data/thunkActions'; const GRADE_OVERRIDE_HISTORY_COLUMNS = [ { label: 'Date', key: 'date' }, @@ -203,8 +201,8 @@ export const mapStateToProps = (state) => { }; export const mapDispatchToProps = { - doneViewingAssignment, - updateGrades, + doneViewingAssignment: actions.grades.doneViewingAssignment, + updateGrades: thunkActions.grades.updateGrades, }; export default connect(mapStateToProps, mapDispatchToProps)(EditModal); diff --git a/src/components/Gradebook/GradebookFilters/AssignmentFilter/index.jsx b/src/components/Gradebook/GradebookFilters/AssignmentFilter/index.jsx index ef89f11..15cc9cf 100644 --- a/src/components/Gradebook/GradebookFilters/AssignmentFilter/index.jsx +++ b/src/components/Gradebook/GradebookFilters/AssignmentFilter/index.jsx @@ -3,12 +3,14 @@ import React from 'react'; import PropTypes from 'prop-types'; import { connect } from 'react-redux'; -import * as gradesActions from 'data/actions/grades'; -import * as filterActions from 'data/actions/filters'; import selectors from 'data/selectors'; +import actions from 'data/actions'; +import thunkActions from 'data/thunkActions'; import SelectGroup from '../SelectGroup'; +const { updateGradesIfAssignmentGradeFiltersSet } = thunkActions.grades; + export class AssignmentFilter extends React.Component { constructor(props) { super(props); @@ -97,8 +99,8 @@ export const mapStateToProps = (state) => { }; export const mapDispatchToProps = { - updateAssignmentFilter: filterActions.updateAssignmentFilter, - updateGradesIfAssignmentGradeFiltersSet: gradesActions.updateGradesIfAssignmentGradeFiltersSet, + updateAssignmentFilter: actions.filters.update.assignment, + updateGradesIfAssignmentGradeFiltersSet, }; export default connect(mapStateToProps, mapDispatchToProps)(AssignmentFilter); diff --git a/src/components/Gradebook/GradebookFilters/AssignmentFilter/test.jsx b/src/components/Gradebook/GradebookFilters/AssignmentFilter/test.jsx index a31a015..f6518f9 100644 --- a/src/components/Gradebook/GradebookFilters/AssignmentFilter/test.jsx +++ b/src/components/Gradebook/GradebookFilters/AssignmentFilter/test.jsx @@ -2,14 +2,18 @@ import React from 'react'; import { mount, shallow } from 'enzyme'; import selectors from 'data/selectors'; -import { updateAssignmentFilter } from 'data/actions/filters'; -import { updateGradesIfAssignmentGradeFiltersSet } from 'data/actions/grades'; +import actions from 'data/actions'; +import { updateGradesIfAssignmentGradeFiltersSet } from 'data/thunkActions/grades'; import { AssignmentFilter, mapStateToProps, mapDispatchToProps, } from '.'; +jest.mock('data/thunkActions/grades', () => ({ + updateGradesIfAssignmentGradeFiltersSet: jest.fn(), +})); + jest.mock('data/selectors', () => ({ /** Mocking to use passed state for validation purposes */ filters: { @@ -160,7 +164,7 @@ describe('AssignmentFilter', () => { describe('mapDispatchToProps', () => { test('updateAssignmentFilter', () => { expect(mapDispatchToProps.updateAssignmentFilter).toEqual( - updateAssignmentFilter, + actions.filters.update.assignment, ); }); test('updateGradesIfAsssignmentGradeFiltersSet', () => { diff --git a/src/components/Gradebook/GradebookFilters/AssignmentGradeFilter/index.jsx b/src/components/Gradebook/GradebookFilters/AssignmentGradeFilter/index.jsx index e962a88..b583bd2 100644 --- a/src/components/Gradebook/GradebookFilters/AssignmentGradeFilter/index.jsx +++ b/src/components/Gradebook/GradebookFilters/AssignmentGradeFilter/index.jsx @@ -5,9 +5,9 @@ import { connect } from 'react-redux'; import { Button } from '@edx/paragon'; -import * as gradesActions from 'data/actions/grades'; -import * as filterActions from 'data/actions/filters'; import selectors from 'data/selectors'; +import actions from 'data/actions'; +import thunkActions from 'data/thunkActions'; import PercentGroup from '../PercentGroup'; @@ -25,10 +25,10 @@ export class AssignmentGradeFilter extends React.Component { assignmentGradeMax, } = this.props.filterValues; - this.props.updateAssignmentLimits( - assignmentGradeMin, - assignmentGradeMax, - ); + this.props.updateAssignmentLimits({ + maxGrade: assignmentGradeMax, + minGrade: assignmentGradeMin, + }); this.props.getUserGrades( this.props.courseId, this.props.selectedCohort, @@ -118,8 +118,8 @@ export const mapStateToProps = (state) => { }; export const mapDispatchToProps = { - getUserGrades: gradesActions.fetchGrades, - updateAssignmentLimits: filterActions.updateAssignmentLimits, + getUserGrades: thunkActions.grades.fetchGrades, + updateAssignmentLimits: actions.filters.update.assignmentLimits, }; export default connect(mapStateToProps, mapDispatchToProps)(AssignmentGradeFilter); diff --git a/src/components/Gradebook/GradebookFilters/AssignmentGradeFilter/test.jsx b/src/components/Gradebook/GradebookFilters/AssignmentGradeFilter/test.jsx index 95b728d..bb67193 100644 --- a/src/components/Gradebook/GradebookFilters/AssignmentGradeFilter/test.jsx +++ b/src/components/Gradebook/GradebookFilters/AssignmentGradeFilter/test.jsx @@ -1,8 +1,8 @@ import React from 'react'; import { mount, shallow } from 'enzyme'; -import { updateAssignmentLimits } from 'data/actions/filters'; -import { fetchGrades } from 'data/actions/grades'; +import actions from 'data/actions'; +import { fetchGrades } from 'data/thunkActions/grades'; import { AssignmentGradeFilter, @@ -43,10 +43,12 @@ describe('AssignmentGradeFilter', () => { el.instance().handleSubmit(); }); it('calls props.updateAssignmentLimits with min and max', () => { - expect(props.updateAssignmentLimits).toHaveBeenCalledWith( - props.filterValues.assignmentGradeMin, - props.filterValues.assignmentGradeMax, - ); + expect( + props.updateAssignmentLimits, + ).toHaveBeenCalledWith({ + maxGrade: props.filterValues.assignmentGradeMax, + minGrade: props.filterValues.assignmentGradeMin, + }); }); it('calls getUserGrades w/ selection', () => { expect(props.getUserGrades).toHaveBeenCalledWith( @@ -167,7 +169,7 @@ describe('AssignmentGradeFilter', () => { expect( mapDispatchToProps.updateAssignmentLimits, ).toEqual( - updateAssignmentLimits, + actions.filters.update.assignmentLimits, ); }); }); diff --git a/src/components/Gradebook/GradebookFilters/AssignmentTypeFilter/index.jsx b/src/components/Gradebook/GradebookFilters/AssignmentTypeFilter/index.jsx index 787cb87..25163c4 100644 --- a/src/components/Gradebook/GradebookFilters/AssignmentTypeFilter/index.jsx +++ b/src/components/Gradebook/GradebookFilters/AssignmentTypeFilter/index.jsx @@ -3,9 +3,8 @@ import React from 'react'; import PropTypes from 'prop-types'; import { connect } from 'react-redux'; -import * as gradesActions from 'data/actions/grades'; import selectors from 'data/selectors'; - +import actions from 'data/actions'; import SelectGroup from '../SelectGroup'; export class AssignmentTypeFilter extends React.Component { @@ -72,7 +71,7 @@ export const mapStateToProps = (state) => ({ }); export const mapDispatchToProps = { - filterAssignmentType: gradesActions.filterAssignmentType, + filterAssignmentType: actions.filters.update.assignmentType, }; export default connect(mapStateToProps, mapDispatchToProps)(AssignmentTypeFilter); diff --git a/src/components/Gradebook/GradebookFilters/AssignmentTypeFilter/test.jsx b/src/components/Gradebook/GradebookFilters/AssignmentTypeFilter/test.jsx index b755630..88750e0 100644 --- a/src/components/Gradebook/GradebookFilters/AssignmentTypeFilter/test.jsx +++ b/src/components/Gradebook/GradebookFilters/AssignmentTypeFilter/test.jsx @@ -2,7 +2,7 @@ import React from 'react'; import { shallow } from 'enzyme'; import selectors from 'data/selectors'; -import { filterAssignmentType } from 'data/actions/grades'; +import actions from 'data/actions'; import { AssignmentTypeFilter, @@ -128,7 +128,7 @@ describe('AssignmentTypeFilter', () => { describe('mapDispatchToProps', () => { test('filterAssignmentType', () => { expect(mapDispatchToProps.filterAssignmentType).toEqual( - filterAssignmentType, + actions.filters.update.assignmentType, ); }); }); diff --git a/src/components/Gradebook/GradebookFilters/CourseGradeFilter/index.jsx b/src/components/Gradebook/GradebookFilters/CourseGradeFilter/index.jsx index fe14bd9..e571cde 100644 --- a/src/components/Gradebook/GradebookFilters/CourseGradeFilter/index.jsx +++ b/src/components/Gradebook/GradebookFilters/CourseGradeFilter/index.jsx @@ -6,9 +6,9 @@ import { Button, } from '@edx/paragon'; -import { updateCourseGradeFilter } from 'data/actions/filters'; -import { fetchGrades } from 'data/actions/grades'; import selectors from 'data/selectors'; +import actions from 'data/actions'; +import thunkActions from 'data/thunkActions'; import PercentGroup from '../PercentGroup'; export class CourseGradeFilter extends React.Component { @@ -37,11 +37,11 @@ export class CourseGradeFilter extends React.Component { updateCourseGradeFilters() { const { courseGradeMin, courseGradeMax } = this.props.filterValues; - this.props.updateFilter( + this.props.updateFilter({ courseGradeMin, courseGradeMax, - this.props.courseId, - ); + courseId: this.props.courseId, + }); this.props.getUserGrades( this.props.courseId, this.props.selectedCohort, @@ -129,8 +129,8 @@ export const mapStateToProps = (state) => { }; export const mapDispatchToProps = { - updateFilter: updateCourseGradeFilter, - getUserGrades: fetchGrades, + updateFilter: actions.filters.update.courseGradeLimits, + getUserGrades: thunkActions.grades.fetchGrades, }; export default connect(mapStateToProps, mapDispatchToProps)(CourseGradeFilter); diff --git a/src/components/Gradebook/GradebookFilters/CourseGradeFilter/test.jsx b/src/components/Gradebook/GradebookFilters/CourseGradeFilter/test.jsx index ae038c4..d0e5475 100644 --- a/src/components/Gradebook/GradebookFilters/CourseGradeFilter/test.jsx +++ b/src/components/Gradebook/GradebookFilters/CourseGradeFilter/test.jsx @@ -3,8 +3,8 @@ import React from 'react'; import { shallow } from 'enzyme'; -import { updateCourseGradeFilter } from 'data/actions/filters'; -import { fetchGrades } from 'data/actions/grades'; +import actions from 'data/actions'; +import { fetchGrades } from 'data/thunkActions/grades'; import { CourseGradeFilter, mapStateToProps, @@ -91,11 +91,11 @@ describe('CourseGradeFilter', () => { el.instance().updateCourseGradeFilters(); }); it('calls props.updateFilter with selection', () => { - expect(props.updateFilter).toHaveBeenCalledWith( - props.filterValues.courseGradeMin, - props.filterValues.courseGradeMax, - props.courseId, - ); + expect(props.updateFilter).toHaveBeenCalledWith({ + courseGradeMin: props.filterValues.courseGradeMin, + courseGradeMax: props.filterValues.courseGradeMax, + courseId: props.courseId, + }); }); it('calls props.getUserGrades with selection', () => { expect(props.getUserGrades).toHaveBeenCalledWith( @@ -184,7 +184,7 @@ describe('CourseGradeFilter', () => { describe('mapDispatchToProps', () => { describe('updateFilter', () => { test('from updateCourseGradeFilter', () => { - expect(mapDispatchToProps.updateFilter).toEqual(updateCourseGradeFilter); + expect(mapDispatchToProps.updateFilter).toEqual(actions.filters.update.courseGradeLimits); }); }); describe('getUserGrades', () => { diff --git a/src/components/Gradebook/GradebookFilters/StudentGroupsFilter/index.jsx b/src/components/Gradebook/GradebookFilters/StudentGroupsFilter/index.jsx index dcfd0e4..adf6f91 100644 --- a/src/components/Gradebook/GradebookFilters/StudentGroupsFilter/index.jsx +++ b/src/components/Gradebook/GradebookFilters/StudentGroupsFilter/index.jsx @@ -3,8 +3,9 @@ import React from 'react'; import { connect } from 'react-redux'; import PropTypes from 'prop-types'; -import { fetchGrades } from 'data/actions/grades'; import selectors from 'data/selectors'; +import thunkActions from 'data/thunkActions'; + import SelectGroup from '../SelectGroup'; export class StudentGroupsFilter extends React.Component { @@ -147,7 +148,7 @@ export const mapStateToProps = (state) => { }; export const mapDispatchToProps = { - getUserGrades: fetchGrades, + getUserGrades: thunkActions.grades.fetchGrades, }; export default connect(mapStateToProps, mapDispatchToProps)(StudentGroupsFilter); diff --git a/src/components/Gradebook/GradebookFilters/StudentGroupsFilter/test.jsx b/src/components/Gradebook/GradebookFilters/StudentGroupsFilter/test.jsx index 729c8d8..16a0655 100644 --- a/src/components/Gradebook/GradebookFilters/StudentGroupsFilter/test.jsx +++ b/src/components/Gradebook/GradebookFilters/StudentGroupsFilter/test.jsx @@ -3,7 +3,7 @@ import React from 'react'; import { shallow } from 'enzyme'; -import { fetchGrades } from 'data/actions/grades'; +import { fetchGrades } from 'data/thunkActions/grades'; import { StudentGroupsFilter, mapStateToProps, diff --git a/src/components/Gradebook/GradebookFilters/index.jsx b/src/components/Gradebook/GradebookFilters/index.jsx index 8613893..0c6f3d7 100644 --- a/src/components/Gradebook/GradebookFilters/index.jsx +++ b/src/components/Gradebook/GradebookFilters/index.jsx @@ -5,7 +5,7 @@ import { connect } from 'react-redux'; import { Collapsible, Form } from '@edx/paragon'; -import * as filterActions from 'data/actions/filters'; +import actions from 'data/actions'; import selectors from 'data/selectors'; import AssignmentTypeFilter from './AssignmentTypeFilter'; @@ -114,7 +114,7 @@ export const mapStateToProps = (state) => ({ }); export const mapDispatchToProps = { - updateIncludeCourseRoleMembers: filterActions.updateIncludeCourseRoleMembers, + updateIncludeCourseRoleMembers: actions.filters.update.includeCourseRoleMembers, }; export default connect(mapStateToProps, mapDispatchToProps)(GradebookFilters); diff --git a/src/components/Gradebook/GradebookFilters/test.jsx b/src/components/Gradebook/GradebookFilters/test.jsx index dfa6d1a..d64aac1 100644 --- a/src/components/Gradebook/GradebookFilters/test.jsx +++ b/src/components/Gradebook/GradebookFilters/test.jsx @@ -1,7 +1,7 @@ import React from 'react'; import { shallow } from 'enzyme'; -import { updateIncludeCourseRoleMembers } from 'data/actions/filters'; +import actions from 'data/actions'; import { GradebookFilters, @@ -98,7 +98,7 @@ describe('GradebookFilters', () => { describe('mapDispatchToProps', () => { test('updateIncludeCourseRoleMembers', () => { expect(mapDispatchToProps.updateIncludeCourseRoleMembers).toEqual( - updateIncludeCourseRoleMembers, + actions.filters.update.includeCourseRoleMembers, ); }); }); diff --git a/src/components/Gradebook/GradebookTable.jsx b/src/components/Gradebook/GradebookTable.jsx index b4a8ffe..42a20d4 100644 --- a/src/components/Gradebook/GradebookTable.jsx +++ b/src/components/Gradebook/GradebookTable.jsx @@ -7,10 +7,10 @@ import { Table, OverlayTrigger, Tooltip, Icon, } from '@edx/paragon'; -import { formatDateForDisplay } from '../../data/actions/utils'; -import { fetchGradeOverrideHistory } from '../../data/actions/grades'; -import { EMAIL_HEADING, TOTAL_COURSE_GRADE_HEADING, USERNAME_HEADING } from '../../data/constants/grades'; -import selectors from '../../data/selectors'; +import { EMAIL_HEADING, TOTAL_COURSE_GRADE_HEADING, USERNAME_HEADING } from 'data/constants/grades'; +import selectors from 'data/selectors'; +import { formatDateForDisplay } from 'data/actions/utils'; +import thunkActions from 'data/thunkActions'; const DECIMAL_PRECISION = 2; @@ -228,7 +228,7 @@ export const mapStateToProps = (state) => { }; export const mapDispatchToProps = { - fetchGradeOverrideHistory, + fetchGradeOverrideHistory: thunkActions.grades.fetchGradeOverrideHistory, }; export default connect(mapStateToProps, mapDispatchToProps)(GradebookTable); diff --git a/src/components/Gradebook/SearchControls.jsx b/src/components/Gradebook/SearchControls.jsx index d02af0b..fa1fb9f 100644 --- a/src/components/Gradebook/SearchControls.jsx +++ b/src/components/Gradebook/SearchControls.jsx @@ -5,10 +5,7 @@ import { connect } from 'react-redux'; import { Button, Icon, SearchField } from '@edx/paragon'; import selectors from 'data/selectors'; -import { - fetchGrades, - fetchMatchingUserGrades, -} from '../../data/actions/grades'; +import thunkActions from 'data/thunkActions'; /** * Controls for filtering the GradebookTable. Contains the "Edit Filters" button for opening the filter drawer @@ -107,8 +104,8 @@ export const mapStateToProps = (state) => { }; export const mapDispatchToProps = { - getUserGrades: fetchGrades, - searchForUser: fetchMatchingUserGrades, + getUserGrades: thunkActions.grades.fetchGrades, + searchForUser: thunkActions.grades.fetchMatchingUserGrades, }; export default connect(mapStateToProps, mapDispatchToProps)(SearchControls); diff --git a/src/components/Gradebook/SearchControls.test.jsx b/src/components/Gradebook/SearchControls.test.jsx index 94b655f..d56f11d 100644 --- a/src/components/Gradebook/SearchControls.test.jsx +++ b/src/components/Gradebook/SearchControls.test.jsx @@ -4,7 +4,7 @@ import { shallow } from 'enzyme'; import { fetchGrades, fetchMatchingUserGrades, -} from '../../data/actions/grades'; +} from '../../data/thunkActions/grades'; import { mapDispatchToProps, mapStateToProps, SearchControls } from './SearchControls'; jest.mock('@edx/paragon', () => ({ diff --git a/src/components/Gradebook/StatusAlerts.jsx b/src/components/Gradebook/StatusAlerts.jsx index 8c67b5f..fcfdbbf 100644 --- a/src/components/Gradebook/StatusAlerts.jsx +++ b/src/components/Gradebook/StatusAlerts.jsx @@ -5,7 +5,7 @@ import { connect } from 'react-redux'; import { StatusAlert } from '@edx/paragon'; import selectors from 'data/selectors'; -import { closeBanner } from '../../data/actions/grades'; +import actions from 'data/actions'; export const maxCourseGradeInvalidMessage = 'Maximum course grade value must be between 0 and 100. '; export const minCourseGradeInvalidMessage = 'Minimum course grade value must be between 0 and 100. '; @@ -66,7 +66,7 @@ export const mapStateToProps = (state) => ({ }); export const mapDispatchToProps = { - handleCloseSuccessBanner: closeBanner, + handleCloseSuccessBanner: actions.grades.banner.close, }; export default connect(mapStateToProps, mapDispatchToProps)(StatusAlerts); diff --git a/src/components/Gradebook/StatusAlerts.test.jsx b/src/components/Gradebook/StatusAlerts.test.jsx index 0f80a5d..6c12419 100644 --- a/src/components/Gradebook/StatusAlerts.test.jsx +++ b/src/components/Gradebook/StatusAlerts.test.jsx @@ -1,5 +1,7 @@ import React from 'react'; import { shallow } from 'enzyme'; + +import actions from 'data/actions'; import { StatusAlerts, mapDispatchToProps, @@ -7,7 +9,6 @@ import { maxCourseGradeInvalidMessage, minCourseGradeInvalidMessage, } from './StatusAlerts'; -import { closeBanner } from '../../data/actions/grades'; jest.mock('@edx/paragon', () => ({ StatusAlert: 'StatusAlert', @@ -92,7 +93,7 @@ describe('StatusAlerts', () => { expect( mapDispatchToProps.handleCloseSuccessBanner, ).toEqual( - closeBanner, + actions.grades.banner.close, ); }); }); diff --git a/src/containers/GradebookPage/index.jsx b/src/containers/GradebookPage/index.jsx index 1d92f42..2ab8e86 100644 --- a/src/containers/GradebookPage/index.jsx +++ b/src/containers/GradebookPage/index.jsx @@ -1,27 +1,10 @@ import { connect } from 'react-redux'; +import thunkActions from 'data/thunkActions'; +import actions from 'data/actions'; import selectors from 'data/selectors'; -import Gradebook from '../../components/Gradebook'; -import { - fetchGradeOverrideHistory, - fetchGrades, - fetchPrevNextGrades, - filterAssignmentType, - submitFileUploadFormData, - toggleGradeFormat, - downloadBulkGradesReport, - downloadInterventionReport, -} from '../../data/actions/grades'; -import { fetchCohorts } from '../../data/actions/cohorts'; -import { fetchTracks } from '../../data/actions/tracks'; -import { - initializeFilters, - resetFilters, - updateAssignmentFilter, - updateAssignmentLimits, -} from '../../data/actions/filters'; -import { fetchAssignmentTypes } from '../../data/actions/assignmentTypes'; -import { getRoles } from '../../data/actions/roles'; + +import Gradebook from 'components/Gradebook'; const mapStateToProps = (state, ownProps) => { const { @@ -61,22 +44,24 @@ const mapStateToProps = (state, ownProps) => { }; const mapDispatchToProps = { - downloadBulkGradesReport, - downloadInterventionReport, - fetchGradeOverrideHistory, - filterAssignmentType, - getAssignmentTypes: fetchAssignmentTypes, - getCohorts: fetchCohorts, - getPrevNextGrades: fetchPrevNextGrades, - getRoles, - getTracks: fetchTracks, - getUserGrades: fetchGrades, - initializeFilters, - resetFilters, - submitFileUploadFormData, - toggleFormat: toggleGradeFormat, - updateAssignmentFilter, - updateAssignmentLimits, + downloadBulkGradesReport: actions.grades.downloadReport.bulkGrades, + downloadInterventionReport: actions.grades.downloadReport.intervention, + toggleFormat: actions.grades.toggleGradeFormat, + + filterAssignmentType: actions.filters.update.assignmentType, + initializeFilters: actions.filters.initialize, + resetFilters: actions.filters.reset, + updateAssignmentFilter: actions.filters.update.assignment, + updateAssignmentLimits: actions.filters.update.assignmentLimits, + + fetchGradeOverrideHistory: thunkActions.grades.fetchGradeOverrideHistory, + getAssignmentTypes: thunkActions.assignmentTypes.fetchAssignmentTypes, + getCohorts: thunkActions.cohorts.fetchCohorts, + getPrevNextGrades: thunkActions.grades.fetchPrevNextGrades, + getRoles: thunkActions.roles.fetchRoles, + getTracks: thunkActions.tracks.fetchTracks, + getUserGrades: thunkActions.grades.fetchGrades, + submitFileUploadFormData: thunkActions.grades.submitFileUploadFormData, }; const GradebookPage = connect( diff --git a/src/data/actions/assignmentTypes.js b/src/data/actions/assignmentTypes.js index 043e058..fea4bab 100644 --- a/src/data/actions/assignmentTypes.js +++ b/src/data/actions/assignmentTypes.js @@ -1,40 +1,17 @@ -import { - STARTED_FETCHING_ASSIGNMENT_TYPES, - GOT_ASSIGNMENT_TYPES, - ERROR_FETCHING_ASSIGNMENT_TYPES, - GOT_ARE_GRADES_FROZEN, -} from '../constants/actionTypes/assignmentTypes'; -import GOT_BULK_MANAGEMENT_CONFIG from '../constants/actionTypes/config'; -import LmsApiService from '../services/LmsApiService'; +import { StrictDict } from 'utils'; +import { createActionFactory } from './utils'; -const startedFetchingAssignmentTypes = () => ({ type: STARTED_FETCHING_ASSIGNMENT_TYPES }); -const errorFetchingAssignmentTypes = () => ({ type: ERROR_FETCHING_ASSIGNMENT_TYPES }); -const gotAssignmentTypes = assignmentTypes => ({ type: GOT_ASSIGNMENT_TYPES, assignmentTypes }); -const gotGradesFrozen = areGradesFrozen => ({ type: GOT_ARE_GRADES_FROZEN, areGradesFrozen }); -const gotBulkManagementConfig = bulkManagementEnabled => ({ - type: GOT_BULK_MANAGEMENT_CONFIG, - data: bulkManagementEnabled, -}); +export const dataKey = 'assignmentTypes'; +const createAction = createActionFactory(dataKey); -const fetchAssignmentTypes = courseId => ( - (dispatch) => { - dispatch(startedFetchingAssignmentTypes()); - return LmsApiService.fetchAssignmentTypes(courseId) - .then(response => response.data) - .then((data) => { - dispatch(gotAssignmentTypes(Object.keys(data.assignment_types))); - dispatch(gotGradesFrozen(data.grades_frozen)); - dispatch(gotBulkManagementConfig(data.can_see_bulk_management)); - }) - .catch(() => { - dispatch(errorFetchingAssignmentTypes()); - }); - } -); - -export { - fetchAssignmentTypes, - startedFetchingAssignmentTypes, - gotAssignmentTypes, - errorFetchingAssignmentTypes, +const fetching = { + error: createAction('fetching/error'), + started: createAction('fetching/started'), + received: createAction('fetching/received'), }; +const gotGradesFrozen = createAction('gotGradesFrozen'); + +export default StrictDict({ + fetching: StrictDict(fetching), + gotGradesFrozen, +}); diff --git a/src/data/actions/assignmentTypes.test.js b/src/data/actions/assignmentTypes.test.js index fa2569b..60a09fe 100644 --- a/src/data/actions/assignmentTypes.test.js +++ b/src/data/actions/assignmentTypes.test.js @@ -1,101 +1,22 @@ -import axios from 'axios'; -import configureMockStore from 'redux-mock-store'; -import MockAdapter from 'axios-mock-adapter'; -import thunk from 'redux-thunk'; - -import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; -import { configuration } from '../../config'; -import { fetchAssignmentTypes } from './assignmentTypes'; -import { - STARTED_FETCHING_ASSIGNMENT_TYPES, - GOT_ASSIGNMENT_TYPES, - ERROR_FETCHING_ASSIGNMENT_TYPES, - GOT_ARE_GRADES_FROZEN, -} from '../constants/actionTypes/assignmentTypes'; -import GOT_BULK_MANAGEMENT_CONFIG from '../constants/actionTypes/config'; - -const mockStore = configureMockStore([thunk]); - -jest.mock('@edx/frontend-platform/auth'); -const axiosMock = new MockAdapter(axios); -getAuthenticatedHttpClient.mockReturnValue(axios); -axios.isAccessTokenExpired = jest.fn(); -axios.isAccessTokenExpired.mockReturnValue(false); +import actions, { dataKey } from './assignmentTypes'; +import { testAction, testActionTypes } from './testUtils'; describe('actions', () => { - afterEach(() => { - axiosMock.reset(); + describe('action types', () => { + const actionTypes = [ + actions.fetching.error, + actions.fetching.started, + actions.fetching.received, + actions.gotGradesFrozen, + ].map(action => action.toString()); + testActionTypes(actionTypes, dataKey); }); - - describe('fetchAssignmentTypes', () => { - const courseId = 'course-v1:edX+DemoX+Demo_Course'; - const responseData = { - assignment_types: { - Exam: { - drop_count: 0, - min_count: 1, - short_label: 'Exam', - type: 'Exam', - weight: 0.25, - }, - Homework: { - drop_count: 1, - min_count: 3, - short_label: 'Ex', - type: 'Homework', - weight: 0.75, - }, - }, - grades_frozen: false, - can_see_bulk_management: true, - }; - it('dispatches success action after fetching fetchAssignmentTypes', () => { - const expectedActions = [ - { type: STARTED_FETCHING_ASSIGNMENT_TYPES }, - { type: GOT_ASSIGNMENT_TYPES, assignmentTypes: Object.keys(responseData.assignment_types) }, - { type: GOT_ARE_GRADES_FROZEN, areGradesFrozen: responseData.grades_frozen }, - { type: GOT_BULK_MANAGEMENT_CONFIG, data: true }, - ]; - const store = mockStore(); - - axiosMock.onGet(`${configuration.LMS_BASE_URL}/api/grades/v1/gradebook/${courseId}/grading-info?graded_only=true`) - .replyOnce(200, JSON.stringify(responseData)); - - return store.dispatch(fetchAssignmentTypes(courseId)).then(() => { - expect(store.getActions()).toEqual(expectedActions); - }); - }); - - it('dispatches failure action after fetching cohorts', () => { - const expectedActions = [ - { type: STARTED_FETCHING_ASSIGNMENT_TYPES }, - { type: ERROR_FETCHING_ASSIGNMENT_TYPES }, - ]; - const store = mockStore(); - - axiosMock.onGet(`${configuration.LMS_BASE_URL}/api/grades/v1/gradebook/${courseId}/grading-info?graded_only=true`) - .replyOnce(500, JSON.stringify({})); - - return store.dispatch(fetchAssignmentTypes(courseId)).then(() => { - expect(store.getActions()).toEqual(expectedActions); - }); - }); - - it('dispatches frozen grade action with True value after fetching', () => { - const expectedActions = [ - { type: STARTED_FETCHING_ASSIGNMENT_TYPES }, - { type: GOT_ASSIGNMENT_TYPES, assignmentTypes: Object.keys(responseData.assignment_types) }, - { type: GOT_ARE_GRADES_FROZEN, areGradesFrozen: true }, - { type: GOT_BULK_MANAGEMENT_CONFIG, data: true }, - ]; - const store = mockStore(); - responseData.grades_frozen = true; - axiosMock.onGet(`${configuration.LMS_BASE_URL}/api/grades/v1/gradebook/${courseId}/grading-info?graded_only=true`) - .replyOnce(200, JSON.stringify(responseData)); - - return store.dispatch(fetchAssignmentTypes(courseId)).then(() => { - expect(store.getActions()).toEqual(expectedActions); - }); + describe('actions provided', () => { + describe('fetching actions', () => { + test('error action', () => testAction(actions.fetching.error)); + test('started action', () => testAction(actions.fetching.started)); + test('received action', () => testAction(actions.fetching.received)); }); + test('gotGradesFrozen action', () => testAction(actions.gotGradesFrozen)); }); }); diff --git a/src/data/actions/cohorts.js b/src/data/actions/cohorts.js index 3d928d6..bfac7e3 100644 --- a/src/data/actions/cohorts.js +++ b/src/data/actions/cohorts.js @@ -1,31 +1,15 @@ -import { - STARTED_FETCHING_COHORTS, - GOT_COHORTS, - ERROR_FETCHING_COHORTS, -} from '../constants/actionTypes/cohorts'; -import LmsApiService from '../services/LmsApiService'; +import { StrictDict } from 'utils'; +import { createActionFactory } from './utils'; -const startedFetchingCohorts = () => ({ type: STARTED_FETCHING_COHORTS }); -const errorFetchingCohorts = () => ({ type: ERROR_FETCHING_COHORTS }); -const gotCohorts = cohorts => ({ type: GOT_COHORTS, cohorts }); +export const dataKey = 'cohorts'; +const createAction = createActionFactory(dataKey); -const fetchCohorts = courseId => ( - (dispatch) => { - dispatch(startedFetchingCohorts()); - return LmsApiService.fetchCohorts(courseId) - .then(response => response.data) - .then((data) => { - dispatch(gotCohorts(data.cohorts)); - }) - .catch(() => { - dispatch(errorFetchingCohorts()); - }); - } -); - -export { - fetchCohorts, - startedFetchingCohorts, - gotCohorts, - errorFetchingCohorts, +const fetching = { + started: createAction('fetching/started'), + error: createAction('fetching/error'), + received: createAction('fetching/received'), }; + +export default StrictDict({ + fetching: StrictDict(fetching), +}); diff --git a/src/data/actions/cohorts.test.js b/src/data/actions/cohorts.test.js index 732a9b8..05ffc02 100644 --- a/src/data/actions/cohorts.test.js +++ b/src/data/actions/cohorts.test.js @@ -1,80 +1,20 @@ -import axios from 'axios'; -import configureMockStore from 'redux-mock-store'; -import MockAdapter from 'axios-mock-adapter'; -import thunk from 'redux-thunk'; +import actions, { dataKey } from './cohorts'; +import { testAction, testActionTypes } from './testUtils'; -import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; -import { configuration } from '../../config'; -import { fetchCohorts } from './cohorts'; -import { - STARTED_FETCHING_COHORTS, - GOT_COHORTS, - ERROR_FETCHING_COHORTS, -} from '../constants/actionTypes/cohorts'; - -const mockStore = configureMockStore([thunk]); - -jest.mock('@edx/frontend-platform/auth'); -const axiosMock = new MockAdapter(axios); -getAuthenticatedHttpClient.mockReturnValue(axios); -axios.isAccessTokenExpired = jest.fn(); -axios.isAccessTokenExpired.mockReturnValue(false); - -describe('actions', () => { - afterEach(() => { - axiosMock.reset(); +describe('actions.cohorts', () => { + describe('action types', () => { + const actionTypes = [ + actions.fetching.error, + actions.fetching.started, + actions.fetching.received, + ].map(action => action.toString()); + testActionTypes(actionTypes, dataKey); }); - - describe('fetchCohorts', () => { - const courseId = 'course-v1:edX+DemoX+Demo_Course'; - - it('dispatches success action after fetching cohorts', () => { - const responseData = { - cohorts: [ - { - assignment_type: 'manual', - group_id: null, - id: 1, - name: 'default_group', - user_count: 2, - user_partition_id: null, - }, - { - assignment_type: 'auto', - group_id: null, - id: 2, - name: 'auto_group', - user_count: 5, - user_partition_id: null, - }], - }; - const expectedActions = [ - { type: STARTED_FETCHING_COHORTS }, - { type: GOT_COHORTS, cohorts: responseData.cohorts }, - ]; - const store = mockStore(); - - axiosMock.onGet(`${configuration.LMS_BASE_URL}/courses/${courseId}/cohorts/`) - .replyOnce(200, JSON.stringify(responseData)); - - return store.dispatch(fetchCohorts(courseId)).then(() => { - expect(store.getActions()).toEqual(expectedActions); - }); - }); - - it('dispatches failure action after fetching cohorts', () => { - const expectedActions = [ - { type: STARTED_FETCHING_COHORTS }, - { type: ERROR_FETCHING_COHORTS }, - ]; - const store = mockStore(); - - axiosMock.onGet(`${configuration.LMS_BASE_URL}/courses/${courseId}/cohorts/`) - .replyOnce(500, JSON.stringify({})); - - return store.dispatch(fetchCohorts(courseId)).then(() => { - expect(store.getActions()).toEqual(expectedActions); - }); + describe('actions provided', () => { + describe('fecthing actions', () => { + test('error action', () => testAction(actions.fetching.error)); + test('started action', () => testAction(actions.fetching.started)); + test('received action', () => testAction(actions.fetching.received)); }); }); }); diff --git a/src/data/actions/config.js b/src/data/actions/config.js new file mode 100644 index 0000000..88cd24f --- /dev/null +++ b/src/data/actions/config.js @@ -0,0 +1,11 @@ +import { StrictDict } from 'utils'; +import { createActionFactory } from './utils'; + +export const dataKey = 'config'; +const createAction = createActionFactory(dataKey); + +const gotBulkManagementConfig = createAction('gotBulkManagement'); + +export default StrictDict({ + gotBulkManagementConfig, +}); diff --git a/src/data/actions/config.test.js b/src/data/actions/config.test.js new file mode 100644 index 0000000..bd70565 --- /dev/null +++ b/src/data/actions/config.test.js @@ -0,0 +1,14 @@ +import actions, { dataKey } from './config'; +import { testAction, testActionTypes } from './testUtils'; + +describe('actions.cohorts', () => { + describe('action types', () => { + const actionTypes = [ + actions.gotBulkManagementConfig, + ].map(action => action.toString()); + testActionTypes(actionTypes, dataKey); + }); + describe('actions provided', () => { + test('gotBulkManagementConfig action', () => testAction(actions.gotBulkManagementConfig)); + }); +}); diff --git a/src/data/actions/filters.js b/src/data/actions/filters.js index aa7e04a..6deb015 100644 --- a/src/data/actions/filters.js +++ b/src/data/actions/filters.js @@ -1,18 +1,11 @@ -import filterSelectors from 'data/selectors/filters'; +import { StrictDict } from 'utils'; import initialFilters from '../constants/filters'; -import { - INITIALIZE_FILTERS, - RESET_FILTERS, - UPDATE_ASSIGNMENT_FILTER, - UPDATE_ASSIGNMENT_LIMITS, - UPDATE_COURSE_GRADE_LIMITS, - UPDATE_INCLUDE_COURSE_ROLE_MEMBERS, -} from '../constants/actionTypes/filters'; -import { fetchGrades } from './grades'; +import { createActionFactory } from './utils'; -const { allFilters } = filterSelectors; +export const dataKey = 'filters'; +const createAction = createActionFactory(dataKey); -const initializeFilters = ({ +const initialize = createAction('initialize', ({ assignment = initialFilters.assignment, assignmentType = initialFilters.assignmentType, track = initialFilters.track, @@ -23,8 +16,7 @@ const initializeFilters = ({ courseGradeMax = initialFilters.assignmentGradeMax, includeCourseRoleMembers = initialFilters.includeCourseRoleMembers, }) => ({ - type: INITIALIZE_FILTERS, - data: { + payload: { assignment: { id: assignment }, assignmentType, track, @@ -35,47 +27,19 @@ const initializeFilters = ({ courseGradeMax, includeCourseRoleMembers: Boolean(includeCourseRoleMembers), }, +})); + +const reset = createAction('reset'); +const update = StrictDict({ + assignment: createAction('update/assignment'), + assignmentType: createAction('update/assignmentType'), + assignmentLimits: createAction('update/assignmentLimits'), + courseGradeLimits: createAction('update/courseGradeLimits'), + includeCourseRoleMembers: createAction('update/includeCourseRoleMembers'), }); -const resetFilters = filterNames => ({ - type: RESET_FILTERS, - filterNames, +export default StrictDict({ + initialize, + reset, + update: StrictDict(update), }); - -const updateAssignmentFilter = assignment => ({ - type: UPDATE_ASSIGNMENT_FILTER, - data: assignment, -}); - -const updateAssignmentLimits = (minGrade, maxGrade) => ({ - type: UPDATE_ASSIGNMENT_LIMITS, - data: { minGrade, maxGrade }, -}); - -const updateCourseGradeFilter = (courseGradeMin, courseGradeMax, courseId) => ({ - type: UPDATE_COURSE_GRADE_LIMITS, - data: { - courseGradeMin, - courseGradeMax, - courseId, - }, -}); - -const updateIncludeCourseRoleMembersFilter = (includeCourseRoleMembers) => ({ - type: UPDATE_INCLUDE_COURSE_ROLE_MEMBERS, - data: { - includeCourseRoleMembers, - }, -}); - -const updateIncludeCourseRoleMembers = includeCourseRoleMembers => (dispatch, getState) => { - dispatch(updateIncludeCourseRoleMembersFilter(includeCourseRoleMembers)); - const state = getState(); - const { cohort, track, assignmentType } = allFilters(state); - dispatch(fetchGrades(state.grades.courseId, cohort, track, assignmentType)); -}; - -export { - initializeFilters, resetFilters, updateAssignmentFilter, - updateAssignmentLimits, updateCourseGradeFilter, updateIncludeCourseRoleMembers, -}; diff --git a/src/data/actions/filters.test.js b/src/data/actions/filters.test.js new file mode 100644 index 0000000..ed06f2c --- /dev/null +++ b/src/data/actions/filters.test.js @@ -0,0 +1,68 @@ +import actions, { dataKey } from './filters'; +import initialFilters from '../constants/filters'; +import { testAction, testActionTypes } from './testUtils'; + +describe('actions.filters', () => { + describe('action types', () => { + const actionTypes = [ + actions.initialize, + actions.reset, + actions.update.assignment, + actions.update.assignmentType, + actions.update.assignmentLimits, + actions.update.courseGradeLimits, + actions.update.includeCourseRoleMembers, + ].map(action => action.toString()); + testActionTypes(actionTypes, dataKey); + }); + describe('actions provided', () => { + describe('initialize action', () => { + it('sets initialFilters values for missing args', () => { + testAction(actions.initialize, {}, { + assignment: { id: initialFilters.assignment }, + assignmentType: initialFilters.assignmentType, + cohort: initialFilters.cohort, + track: initialFilters.track, + assignmentGradeMin: initialFilters.assignmentGradeMin, + assignmentGradeMax: initialFilters.assignmentGradeMax, + courseGradeMin: initialFilters.courseGradeMin, + courseGradeMax: initialFilters.courseGradeMax, + includeCourseRoleMembers: initialFilters.includeCourseRoleMembers, + }); + }); + it('loads filters from args', () => { + const expected = { + assignment: { id: 'assIGNmentId' }, + assignmentType: 'aType', + track: 'masters', + cohort: 3, + assignmentGradeMin: 23, + assignmentGradeMax: 98, + courseGradeMin: 11, + courseGradeMax: 87, + includeCourseRoleMembers: true, + }; + const args = { ...expected, assignment: expected.assignment.id, also: 'other stuff' }; + testAction(actions.initialize, args, expected); + }); + }); + test('reset action', () => testAction(actions.reset)); + describe('update actions', () => { + test('update.assignment action', () => ( + testAction(actions.update.assignment) + )); + test('update.assignmentType action', () => ( + testAction(actions.update.assignmentType) + )); + test('update.assignmentLimits action', () => ( + testAction(actions.update.assignmentLimits) + )); + test('update.courseGradeLimits action', () => ( + testAction(actions.update.courseGradeLimits) + )); + test('update.includeCourseRoleMembers action', () => ( + testAction(actions.update.includeCourseRoleMembers) + )); + }); + }); +}); diff --git a/src/data/actions/grades.js b/src/data/actions/grades.js index 3c83931..a15b3a2 100644 --- a/src/data/actions/grades.js +++ b/src/data/actions/grades.js @@ -1,359 +1,103 @@ -import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; -import gradesSelectors from 'data/selectors/grades'; -import filtersSelectors from 'data/selectors/filters'; -import { - STARTED_FETCHING_GRADES, - FINISHED_FETCHING_GRADES, - ERROR_FETCHING_GRADES, - GOT_GRADES, - GRADE_UPDATE_REQUEST, - GRADE_UPDATE_SUCCESS, - GRADE_UPDATE_FAILURE, - TOGGLE_GRADE_FORMAT, - FILTER_BY_ASSIGNMENT_TYPE, - OPEN_BANNER, - CLOSE_BANNER, - START_UPLOAD, - UPLOAD_COMPLETE, - UPLOAD_ERR, - GOT_BULK_HISTORY, - BULK_HISTORY_ERR, - GOT_GRADE_OVERRIDE_HISTORY, - DONE_VIEWING_ASSIGNMENT, - ERROR_FETCHING_GRADE_OVERRIDE_HISTORY, - UPLOAD_OVERRIDE, - UPLOAD_OVERRIDE_ERROR, - BULK_GRADE_REPORT_DOWNLOADED, - INTERVENTION_REPORT_DOWNLOADED, -} from '../constants/actionTypes/grades'; -import GRADE_OVERRIDE_HISTORY_ERROR_DEFAULT_MSG from '../constants/errors'; -import LmsApiService from '../services/LmsApiService'; -import { sortAlphaAsc, formatDateForDisplay } from './utils'; +import { StrictDict } from 'utils'; +import { createActionFactory } from './utils'; -const { - formatMaxAssignmentGrade, - formatMinAssignmentGrade, - formatMaxCourseGrade, - formatMinCourseGrade, -} = gradesSelectors; -const { allFilters } = filtersSelectors; +export const dataKey = 'grades'; +const createAction = createActionFactory(dataKey); -const defaultAssignmentFilter = 'All'; +const banner = { + open: createAction('banner/open'), + close: createAction('banner/close'), +}; -const startedCsvUpload = () => ({ type: START_UPLOAD }); -const finishedCsvUpload = () => ({ type: UPLOAD_COMPLETE }); -const csvUploadError = data => ({ type: UPLOAD_ERR, data }); -const gotBulkHistory = data => ({ type: GOT_BULK_HISTORY, data }); -const bulkHistoryError = () => ({ type: BULK_HISTORY_ERR }); +const bulkHistory = { + received: createAction('bulkHistory/received'), + error: createAction('bulkHistory/error'), +}; -const startedFetchingGrades = () => ({ type: STARTED_FETCHING_GRADES }); -const finishedFetchingGrades = () => ({ type: FINISHED_FETCHING_GRADES }); -const errorFetchingGrades = () => ({ type: ERROR_FETCHING_GRADES }); -const errorFetchingGradeOverrideHistory = errorMessage => ({ - type: ERROR_FETCHING_GRADE_OVERRIDE_HISTORY, - errorMessage, -}); +const csvUpload = { + started: createAction('csvUpload/started'), + finished: createAction('csvUpload/finished'), + error: createAction('csvUpload/error'), +}; -const gotGrades = ({ - grades, cohort, track, assignmentType, headings, prev, - next, courseId, totalUsersCount, filteredUsersCount, -}) => ({ - type: GOT_GRADES, - grades, - cohort, - track, - assignmentType, - headings, - prev, - next, - courseId, - totalUsersCount, - filteredUsersCount, -}); +const doneViewingAssignment = createAction('doneViewingAssignment'); -const gotGradeOverrideHistory = ({ - overrideHistory, currentEarnedAllOverride, currentPossibleAllOverride, - currentEarnedGradedOverride, currentPossibleGradedOverride, - originalGradeEarnedAll, originalGradePossibleAll, originalGradeEarnedGraded, - originalGradePossibleGraded, -}) => ({ - type: GOT_GRADE_OVERRIDE_HISTORY, - overrideHistory, - currentEarnedAllOverride, - currentPossibleAllOverride, - currentEarnedGradedOverride, - currentPossibleGradedOverride, - originalGradeEarnedAll, - originalGradePossibleAll, - originalGradeEarnedGraded, - originalGradePossibleGraded, -}); +// for segment tracking +const downloadReport = { + bulkGrades: createAction('downloadReport/bulkGrades'), + intervention: createAction('downloadReport/intervention'), +}; -const gradeUpdateRequest = () => ({ type: GRADE_UPDATE_REQUEST }); -const gradeUpdateSuccess = (courseId, responseData) => ({ - type: GRADE_UPDATE_SUCCESS, - courseId, - payload: { responseData }, -}); -const gradeUpdateFailure = (courseId, error) => ({ - type: GRADE_UPDATE_FAILURE, - courseId, - payload: { error }, -}); -const uploadOverrideSuccess = courseId => ({ - type: UPLOAD_OVERRIDE, - courseId, -}); -// This action for google analytics only. Doesn't change redux state. -const downloadBulkGradesReport = courseId => ({ - type: BULK_GRADE_REPORT_DOWNLOADED, - courseId, -}); -// This action for google analytics only. Doesn't change redux state. -const downloadInterventionReport = courseId => ({ - type: INTERVENTION_REPORT_DOWNLOADED, - courseId, -}); -const uploadOverrideFailure = (courseId, error) => ({ - type: UPLOAD_OVERRIDE_ERROR, - courseId, - payload: { error }, -}); - -const toggleGradeFormat = formatType => ({ type: TOGGLE_GRADE_FORMAT, formatType }); - -const filterAssignmentType = filterType => ( - dispatch => dispatch({ - type: FILTER_BY_ASSIGNMENT_TYPE, - filterType, - }) -); - -const openBanner = () => ({ type: OPEN_BANNER }); -const closeBanner = () => ({ type: CLOSE_BANNER }); - -const fetchGrades = ( - courseId, - cohort, - track, - assignmentType, - options = {}, -) => ( - (dispatch, getState) => { - dispatch(startedFetchingGrades()); - const { - assignment, - assignmentGradeMax: assignmentMax, - assignmentGradeMin: assignmentMin, - courseGradeMin, - courseGradeMax, - includeCourseRoleMembers, - } = allFilters(getState()); - const { id: assignmentId } = assignment || {}; - const assignmentGradeMax = formatMaxAssignmentGrade(assignmentMax, { assignmentId }); - const assignmentGradeMin = formatMinAssignmentGrade(assignmentMin, { assignmentId }); - const courseGradeMinFormatted = formatMinCourseGrade(courseGradeMin); - const courseGradeMaxFormatted = formatMaxCourseGrade(courseGradeMax); - return LmsApiService.fetchGradebookData( - courseId, - options.searchText || null, - cohort, - track, - { - assignment: assignmentId, - assignmentGradeMax, - assignmentGradeMin, - courseGradeMin: courseGradeMinFormatted, - courseGradeMax: courseGradeMaxFormatted, - includeCourseRoleMembers, +const fetching = { + started: createAction('fetching/started'), + finished: createAction('fetching/finished'), + error: createAction('fetching/error'), + // for segment tracking + received: createAction( + 'fetching/received', + (data) => ({ + payload: { + grades: data.grades, + cohort: data.cohort, + track: data.track, + assignmentType: data.assignmentType, + headings: data.headings, + prev: data.prev, + next: data.next, + courseId: data.courseId, + totalUsersCount: data.totalUsersCount, + filteredUsersCount: data.filteredUsersCount, }, - - ) - .then(response => response.data) - .then((data) => { - dispatch(gotGrades({ - grades: data.results.sort(sortAlphaAsc), - cohort, - track, - assignmentType, - prev: data.previous, - next: data.next, - courseId, - totalUsersCount: data.total_users_count, - filteredUsersCount: data.filtered_users_count, - })); - dispatch(finishedFetchingGrades()); - if (options.showSuccess) { - dispatch(openBanner()); - } - }) - .catch(() => { - dispatch(errorFetchingGrades()); - }); - } -); - -const formatGradeOverrideForDisplay = historyArray => historyArray.map(item => ({ - date: formatDateForDisplay(new Date(item.history_date)), - grader: item.history_user, - reason: item.override_reason, - adjustedGrade: item.earned_graded_override, -})); - -const doneViewingAssignment = () => dispatch => dispatch({ - type: DONE_VIEWING_ASSIGNMENT, -}); -const fetchGradeOverrideHistory = (subsectionId, userId) => ( - dispatch => LmsApiService.fetchGradeOverrideHistory(subsectionId, userId) - .then(response => response.data) - .then((data) => { - if (data.success) { - dispatch(gotGradeOverrideHistory({ - overrideHistory: formatGradeOverrideForDisplay(data.history), - currentEarnedAllOverride: data.override ? data.override.earned_all_override : null, - currentPossibleAllOverride: data.override ? data.override.possible_all_override : null, - currentEarnedGradedOverride: data.override ? data.override.earned_graded_override : null, - currentPossibleGradedOverride: data.override - ? data.override.possible_graded_override : null, - originalGradeEarnedAll: data.original_grade ? data.original_grade.earned_all : null, - originalGradePossibleAll: data.original_grade ? data.original_grade.possible_all : null, - originalGradeEarnedGraded: data.original_grade - ? data.original_grade.earned_graded : null, - originalGradePossibleGraded: data.original_grade - ? data.original_grade.possible_graded : null, - })); - } else { - dispatch(errorFetchingGradeOverrideHistory(data.error_message)); - } - }) - .catch(() => { - dispatch(errorFetchingGradeOverrideHistory(GRADE_OVERRIDE_HISTORY_ERROR_DEFAULT_MSG)); - }) -); - -const fetchMatchingUserGrades = ( - courseId, - searchText, - cohort, - track, - assignmentType, - showSuccess, - options = {}, -) => { - const newOptions = { ...options, searchText, showSuccess }; - return fetchGrades(courseId, cohort, track, assignmentType, newOptions); + }), + ), }; -const fetchPrevNextGrades = (endpoint, courseId, cohort, track, assignmentType) => ( - (dispatch) => { - dispatch(startedFetchingGrades()); - return getAuthenticatedHttpClient().get(endpoint) - .then(response => response.data) - .then((data) => { - dispatch(gotGrades({ - grades: data.results.sort(sortAlphaAsc), - cohort, - track, - assignmentType, - prev: data.previous, - next: data.next, - courseId, - totalUsersCount: data.total_users_count, - filteredUsersCount: data.filtered_users_count, - })); - dispatch(finishedFetchingGrades()); - }) - .catch(() => { - dispatch(errorFetchingGrades()); - }); - } -); - -const updateGrades = (courseId, updateData, searchText, cohort, track) => ( - (dispatch) => { - dispatch(gradeUpdateRequest()); - return LmsApiService.updateGradebookData(courseId, updateData) - .then(response => response.data) - .then((data) => { - dispatch(gradeUpdateSuccess(courseId, data)); - dispatch(fetchMatchingUserGrades( - courseId, - searchText, - cohort, - track, - defaultAssignmentFilter, - true, - { searchText }, - )); - }) - .catch((error) => { - dispatch(gradeUpdateFailure(courseId, error)); - }); - } -); - -const submitFileUploadFormData = (courseId, formData) => ( - (dispatch) => { - dispatch(startedCsvUpload()); - return LmsApiService.uploadGradeCsv(courseId, formData).then(() => { - dispatch(finishedCsvUpload()); - dispatch(uploadOverrideSuccess(courseId)); - }).catch((err) => { - dispatch(uploadOverrideFailure(courseId, err)); - if (err.status === 200 && err.data.error_messages.length) { - const { error_messages: errorMessages, saved, total } = err.data; - return dispatch(csvUploadError({ errorMessages, saved, total })); - } - return dispatch(csvUploadError({ errorMessages: ['Unknown error.'] })); - }); - } -); - -const fetchBulkUpgradeHistory = courseId => ( - // todo add loading effect - dispatch => LmsApiService.fetchGradeBulkOperationHistory(courseId).then( - (response) => { dispatch(gotBulkHistory(response)); }, - ).catch(() => dispatch(bulkHistoryError())) -); - -const updateGradesIfAssignmentGradeFiltersSet = ( - courseId, - cohort, - track, - assignmentType, -) => (dispatch, getState) => { - const { filters } = getState(); - const hasAssignmentGradeFiltersSet = filters.assignmentGradeMax || filters.assignmentGradeMin; - if (hasAssignmentGradeFiltersSet) { - dispatch(fetchGrades( - courseId, - cohort, - track, - assignmentType, - )); - } +const overrideHistory = { + error: createAction('overrideHistory/errorFetching'), + received: createAction( + 'overrideHistory/received', + (data) => ({ + payload: { + overrideHistory: data.overrideHistory, + currentEarnedAllOverride: data.currentEarnedAllOverride, + currentPossibleAllOverride: data.currentPossibleAllOverride, + currentEarnedGradedOverride: data.currentEarnedGradedOverride, + currentPossibleGradedOverride: data.currentPossibleGradedOverride, + originalGradeEarnedAll: data.originalGradeEarnedAll, + originalGradePossibleAll: data.originalGradePossibleAll, + originalGradeEarnedGraded: data.originalGradeEarnedGraded, + originalGradePossibleGraded: data.originalGradePossibleGraded, + }, + }), + ), }; -export { - startedFetchingGrades, - finishedFetchingGrades, - errorFetchingGrades, - gotGrades, - fetchGrades, - fetchMatchingUserGrades, - fetchPrevNextGrades, - gradeUpdateRequest, - gradeUpdateSuccess, - gradeUpdateFailure, - updateGrades, - toggleGradeFormat, - filterAssignmentType, - closeBanner, - submitFileUploadFormData, - fetchBulkUpgradeHistory, +const toggleGradeFormat = createAction('toggleGradeFormat'); + +const update = { + request: createAction('update/request'), + success: createAction('update/success'), + failure: createAction('update/failure', (courseId, error) => ({ + payload: { courseId, error }, + })), +}; + +const uploadOverride = { + success: createAction('uploadOverride/success'), + failure: createAction('uploadOverride/failure', (courseId, error) => ({ + payload: { courseId, error }, + })), +}; + +export default StrictDict({ + banner: StrictDict(banner), + bulkHistory: StrictDict(bulkHistory), + csvUpload: StrictDict(csvUpload), doneViewingAssignment, - fetchGradeOverrideHistory, - updateGradesIfAssignmentGradeFiltersSet, - downloadBulkGradesReport, - downloadInterventionReport, -}; + downloadReport: StrictDict(downloadReport), + fetching: StrictDict(fetching), + overrideHistory: StrictDict(overrideHistory), + toggleGradeFormat, + update: StrictDict(update), + uploadOverride: StrictDict(uploadOverride), +}); diff --git a/src/data/actions/grades.test.js b/src/data/actions/grades.test.js index 82170ef..2ce2d62 100644 --- a/src/data/actions/grades.test.js +++ b/src/data/actions/grades.test.js @@ -1,274 +1,115 @@ -import axios from 'axios'; -import configureMockStore from 'redux-mock-store'; -import MockAdapter from 'axios-mock-adapter'; -import thunk from 'redux-thunk'; +import actions, { dataKey } from './grades'; +import { testAction, testActionTypes } from './testUtils'; -import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; -import { configuration } from '../../config'; -import { fetchGrades, fetchGradeOverrideHistory } from './grades'; -import { - STARTED_FETCHING_GRADES, - FINISHED_FETCHING_GRADES, - ERROR_FETCHING_GRADES, - GOT_GRADES, - GOT_GRADE_OVERRIDE_HISTORY, - ERROR_FETCHING_GRADE_OVERRIDE_HISTORY, -} from '../constants/actionTypes/grades'; -import GRADE_OVERRIDE_HISTORY_ERROR_DEFAULT_MSG from '../constants/errors'; -import { sortAlphaAsc } from './utils'; -import LmsApiService from '../services/LmsApiService'; - -const mockStore = configureMockStore([thunk]); - -jest.mock('@edx/frontend-platform/auth'); -const axiosMock = new MockAdapter(axios); -getAuthenticatedHttpClient.mockReturnValue(axios); -axios.isAccessTokenExpired = jest.fn(); -axios.isAccessTokenExpired.mockReturnValue(false); - -describe('actions', () => { - afterEach(() => { - axiosMock.reset(); +describe('actions.grades', () => { + describe('action types', () => { + const actionTypes = [ + actions.banner.open, + actions.banner.close, + actions.bulkHistory.received, + actions.bulkHistory.error, + actions.csvUpload.started, + actions.csvUpload.finished, + actions.csvUpload.error, + actions.doneViewingAssignment, + actions.downloadReport.bulkGrades, + actions.downloadReport.intervention, + actions.fetching.started, + actions.fetching.finished, + actions.fetching.error, + actions.fetching.received, + actions.overrideHistory.error, + actions.overrideHistory.received, + actions.toggleGradeFormat, + actions.update.request, + actions.update.success, + actions.update.failure, + actions.uploadOverride.success, + actions.uploadOverride.failure, + ].map(action => action.toString()); + testActionTypes(actionTypes, dataKey); }); - - describe('fetchGrades', () => { - const courseId = 'course-v1:edX+DemoX+Demo_Course'; - const expectedCohort = 1; - const expectedTrack = 'verified'; - const expectedAssignmentType = 'Exam'; - const fetchGradesURL = `${configuration.LMS_BASE_URL}/api/grades/v1/gradebook/${courseId}/?page_size=25&cohort_id=${expectedCohort}&enrollment_mode=${expectedTrack}&excluded_course_roles=all`; - const responseData = { - next: `${fetchGradesURL}&cursor=2344fda`, - previous: null, - results: [ - { - course_id: courseId, - email: 'user1@example.com', - username: 'user1', - user_id: 1, - percent: 0.5, - letter_grade: null, - section_breakdown: [ - { - subsection_name: 'Demo Course Overview', - score_earned: 0, - score_possible: 0, - percent: 0, - displayed_value: '0.00', - grade_description: '(0.00/0.00)', - }, - { - subsection_name: 'Example Week 1: Getting Started', - score_earned: 1, - score_possible: 1, - percent: 1, - displayed_value: '1.00', - grade_description: '(0.00/0.00)', - }, - ], - }, - { - course_id: courseId, - email: 'user22@example.com', - username: 'user22', - user_id: 22, - percent: 0, - letter_grade: null, - section_breakdown: [ - { - subsection_name: 'Demo Course Overview', - score_earned: 0, - score_possible: 0, - percent: 0, - displayed_value: '0.00', - grade_description: '(0.00/0.00)', - }, - { - subsection_name: 'Example Week 1: Getting Started', - score_earned: 1, - score_possible: 1, - percent: 0, - displayed_value: '0.00', - grade_description: '(0.00/0.00)', - }, - ], - }], - }; - - it('dispatches success action after fetching grades', () => { - const expectedActions = [ - { type: STARTED_FETCHING_GRADES }, - { - type: GOT_GRADES, - grades: responseData.results.sort(sortAlphaAsc), - cohort: expectedCohort, - track: expectedTrack, - assignmentType: expectedAssignmentType, - prev: responseData.previous, - next: responseData.next, - courseId, - }, - { type: FINISHED_FETCHING_GRADES }, - ]; - const store = mockStore(); - - axiosMock.onGet(fetchGradesURL) - .replyOnce(200, JSON.stringify(responseData)); - - return store.dispatch(fetchGrades( - courseId, - expectedCohort, - expectedTrack, - expectedAssignmentType, - false, - )).then(() => { - expect(store.getActions()).toEqual(expectedActions); - }); + describe('actions provided', () => { + describe('banner', () => { + test('open action', () => testAction(actions.banner.open)); + test('close action', () => testAction(actions.banner.close)); }); - - it('dispatches failure action after fetching grades', () => { - const expectedActions = [ - { type: STARTED_FETCHING_GRADES }, - { type: ERROR_FETCHING_GRADES }, - ]; - const store = mockStore(); - - axiosMock.onGet(fetchGradesURL) - .replyOnce(500, JSON.stringify({})); - - return store.dispatch(fetchGrades( - courseId, - expectedCohort, - expectedTrack, - expectedAssignmentType, - false, - )).then(() => { - expect(store.getActions()).toEqual(expectedActions); - }); + describe('bulkHistory', () => { + test('received action', () => testAction(actions.bulkHistory.received)); + test('error action', () => testAction(actions.bulkHistory.error)); }); - - it('dispatches success action on empty response after fetching grades', () => { - const emptyResponseData = { - next: responseData.next, - previous: responseData.previous, - results: [], - }; - const expectedActions = [ - { type: STARTED_FETCHING_GRADES }, - { - type: GOT_GRADES, - grades: [], - cohort: expectedCohort, - track: expectedTrack, - assignmentType: expectedAssignmentType, - prev: responseData.previous, - next: responseData.next, - courseId, - }, - { type: FINISHED_FETCHING_GRADES }, - ]; - const store = mockStore(); - - axiosMock.onGet(fetchGradesURL) - .replyOnce(200, JSON.stringify(emptyResponseData)); - - return store.dispatch(fetchGrades( - courseId, - expectedCohort, - expectedTrack, - expectedAssignmentType, - false, - )).then(() => { - expect(store.getActions()).toEqual(expectedActions); - }); + describe('csvUpload', () => { + test('started action', () => testAction(actions.csvUpload.started)); + test('finished action', () => testAction(actions.csvUpload.finished)); + test('error action', () => testAction(actions.csvUpload.error)); }); - }); - - describe('fetchGradeOverridHistory', () => { - const subsectionId = 'subsectionId-11111'; - const userId = 'user-id-11111'; - - const fetchOverridesURL = `${LmsApiService.baseUrl}/api/grades/v1/subsection/${subsectionId}/?user_id=${userId}&history_record_limit=5`; - - const originalGrade = { - earned_all: 1.0, - possible_all: 12.0, - earned_graded: 3.0, - possible_graded: 8.0, - }; - - const override = { - earned_all_override: 13.0, - possible_all_override: 13.0, - earned_graded_override: 10.0, - possible_graded_override: 10.0, - }; - - it('dispatches success action after successfully getting override info', () => { - const responseData = { - success: true, - original_grade: originalGrade, - history: [], - override, - }; - - axiosMock.onGet(fetchOverridesURL) - .replyOnce(200, JSON.stringify(responseData)); - - const expectedActions = [ - { - type: GOT_GRADE_OVERRIDE_HISTORY, - overrideHistory: [], - currentEarnedAllOverride: override.earned_all_override, - currentPossibleAllOverride: override.possible_all_override, - currentEarnedGradedOverride: override.earned_graded_override, - currentPossibleGradedOverride: override.possible_graded_override, - originalGradeEarnedAll: originalGrade.earned_all, - originalGradePossibleAll: originalGrade.possible_all, - originalGradeEarnedGraded: originalGrade.earned_graded, - originalGradePossibleGraded: originalGrade.possible_graded, - }, - ]; - const store = mockStore(); - - return store.dispatch(fetchGradeOverrideHistory(subsectionId, userId)).then(() => { - expect(store.getActions()).toEqual(expectedActions); - }); + test('doneViewingAssignment', () => testAction(actions.doneViewingAssignment)); + describe('downloadReport', () => { + test('bulkGrades action', () => testAction(actions.downloadReport.bulkGrades)); + test('intervention action', () => testAction(actions.downloadReport.intervention)); }); - - describe('dispatches failure action with expected message', () => { - test('on failure response', () => { - const responseData = { - success: false, - error_message: 'There was an error!!!!!!!!!', - }; - - axiosMock.onGet(fetchOverridesURL).replyOnce(200, JSON.stringify(responseData)); - - const expectedActions = [{ - type: ERROR_FETCHING_GRADE_OVERRIDE_HISTORY, - errorMessage: responseData.error_message, - }]; - const store = mockStore(); - - return store.dispatch(fetchGradeOverrideHistory(subsectionId, userId)).then(() => { - expect(store.getActions()).toEqual(expectedActions); + describe('fetching', () => { + test('started action', () => testAction(actions.fetching.started)); + test('finished action', () => testAction(actions.fetching.finished)); + test('error action', () => testAction(actions.fetching.error)); + describe('received', () => { + it('loads grades data from data', () => { + const data = { + grades: ['some', 'grades'], + cohort: 2, + track: 'summoners', + assignmentType: 'potion', + headings: ['H', 'E', 'a', 'd', 'Ing', 'sssss'], + prev: 'prEEEV', + next: 'NEEEExt', + courseId: 'fake ID', + totalUsersCount: 2, + filteredUsersCount: 999, + }; + testAction(actions.fetching.received, { ...data, other: 'fields' }, data); }); }); - - test('on 500 error', () => { - axiosMock.onGet(fetchOverridesURL).replyOnce(500); - - const expectedActions = [{ - type: ERROR_FETCHING_GRADE_OVERRIDE_HISTORY, - errorMessage: GRADE_OVERRIDE_HISTORY_ERROR_DEFAULT_MSG, - }]; - const store = mockStore(); - - return store.dispatch(fetchGradeOverrideHistory(subsectionId, userId)).then(() => { - expect(store.getActions()).toEqual(expectedActions); + }); + describe('overrideHistory', () => { + test('error action', () => testAction(actions.overrideHistory.error)); + describe('received', () => { + it('loads override history from data', () => { + const data = { + overrideHistory: 'some History', + currentEarnedAllOverride: 123, + currentPossibleAllOverride: 243, + currentEarnedGradedOverride: 1236, + currentPossibleGradedOverride: 52, + originalGradeEarnedAll: 323, + originalGradePossibleAll: 6223, + originalGradeEarnedGraded: 1232, + originalGradePossibleGraded: 512, + }; + testAction(actions.overrideHistory.received, { ...data, other: 'fields' }, data); }); }); }); + test('toggleGradeFormat', () => testAction(actions.toggleGradeFormat)); + describe('update', () => { + const courseId = 'fake ID'; + const error = 'Try Again??'; + test('request action', () => testAction(actions.update.request)); + test('success action', () => testAction(actions.update.success)); + test('failure action', () => testAction( + actions.update.failure, + [courseId, error], + { courseId, error }, + )); + }); + describe('uploadOverride', () => { + const courseId = 'fake ID'; + const error = 'Try Again??'; + test('success action', () => testAction(actions.uploadOverride.success)); + test('failure action', () => testAction( + actions.uploadOverride.failure, + [courseId, error], + { courseId, error }, + )); + }); }); }); diff --git a/src/data/actions/index.js b/src/data/actions/index.js new file mode 100644 index 0000000..4e3f972 --- /dev/null +++ b/src/data/actions/index.js @@ -0,0 +1,19 @@ +import { StrictDict } from 'utils'; + +import assignmentTypes from './assignmentTypes'; +import cohorts from './cohorts'; +import config from './config'; +import filters from './filters'; +import grades from './grades'; +import roles from './roles'; +import tracks from './tracks'; + +export default StrictDict({ + assignmentTypes, + cohorts, + config, + filters, + grades, + roles, + tracks, +}); diff --git a/src/data/actions/roles.js b/src/data/actions/roles.js index 9b3b42b..9722d42 100644 --- a/src/data/actions/roles.js +++ b/src/data/actions/roles.js @@ -1,46 +1,14 @@ -import filtersSelectors from 'data/selectors/filters'; -import { - GOT_ROLES, - ERROR_FETCHING_ROLES, -} from '../constants/actionTypes/roles'; -import { fetchGrades } from './grades'; -import { fetchTracks } from './tracks'; -import { fetchCohorts } from './cohorts'; -import { fetchAssignmentTypes } from './assignmentTypes'; -import LmsApiService from '../services/LmsApiService'; +import { StrictDict } from 'utils'; +import { createActionFactory } from './utils'; -const { allFilters } = filtersSelectors; +export const dataKey = 'roles'; +const createAction = createActionFactory(dataKey); -const allowedRoles = ['staff', 'instructor', 'support']; - -const gotRoles = (canUserViewGradebook, courseId) => ({ - type: GOT_ROLES, - canUserViewGradebook, - courseId, -}); -const errorFetchingRoles = () => ({ type: ERROR_FETCHING_ROLES }); - -const getRoles = courseId => ( - (dispatch, getState) => LmsApiService.fetchUserRoles(courseId) - .then(response => response.data) - .then((response) => { - const canUserViewGradebook = response.is_staff - || (response.roles.some(role => (role.course_id === courseId) - && allowedRoles.includes(role.role))); - dispatch(gotRoles(canUserViewGradebook, courseId)); - const { cohort, track, assignmentType } = allFilters(getState()); - if (canUserViewGradebook) { - dispatch(fetchGrades(courseId, cohort, track, assignmentType)); - dispatch(fetchTracks(courseId)); - dispatch(fetchCohorts(courseId)); - dispatch(fetchAssignmentTypes(courseId)); - } - }) - .catch(() => { - dispatch(errorFetchingRoles()); - })); - -export { - getRoles, - errorFetchingRoles, +const fetching = { + error: createAction('fetching/error'), + received: createAction('fetching/received'), }; + +export default StrictDict({ + fetching: StrictDict(fetching), +}); diff --git a/src/data/actions/roles.test.js b/src/data/actions/roles.test.js index 2b3c668..63c5b02 100644 --- a/src/data/actions/roles.test.js +++ b/src/data/actions/roles.test.js @@ -1,166 +1,18 @@ -import axios from 'axios'; -import configureMockStore from 'redux-mock-store'; -import MockAdapter from 'axios-mock-adapter'; -import thunk from 'redux-thunk'; -import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; +import actions, { dataKey } from './roles'; +import { testAction, testActionTypes } from './testUtils'; -import { configuration } from '../../config'; -import { getRoles } from './roles'; -import { - GOT_ROLES, - ERROR_FETCHING_ROLES, -} from '../constants/actionTypes/roles'; -import { STARTED_FETCHING_GRADES } from '../constants/actionTypes/grades'; -import { STARTED_FETCHING_TRACKS } from '../constants/actionTypes/tracks'; -import { STARTED_FETCHING_COHORTS } from '../constants/actionTypes/cohorts'; -import { STARTED_FETCHING_ASSIGNMENT_TYPES } from '../constants/actionTypes/assignmentTypes'; - -const mockStore = configureMockStore([thunk]); - -jest.mock('@edx/frontend-platform/auth'); -const axiosMock = new MockAdapter(axios); -getAuthenticatedHttpClient.mockReturnValue(axios); -axios.isAccessTokenExpired = jest.fn(); -axios.isAccessTokenExpired.mockReturnValue(false); - -const course1Id = 'course-v1:edX+DemoX+Demo_Course'; -const course2Id = 'course-v1:edX+DemoX+Demo_Course_2'; -const rolesUrl = `${configuration.LMS_BASE_URL}/api/enrollment/v1/roles/?course_id=${encodeURIComponent(course1Id)}`; - -function makeRoleListObj(roles, isGlobalStaff) { - return { - roles, - is_staff: isGlobalStaff, - }; -} -function makeRoleObj(courseId, role) { - return { - course_id: courseId, - role, - }; -} - -const course1StaffRole = makeRoleObj(course1Id, 'staff'); -const course1DummyRole = makeRoleObj(course1Id, 'dummy'); -const course2StaffRole = makeRoleObj(course2Id, 'staff'); -const course2DummyRole = makeRoleObj(course2Id, 'dummy'); -const urlParams = { cohort: null, track: null }; - -describe('actions', () => { - afterEach(() => { - axiosMock.reset(); +describe('actions.roles', () => { + describe('action types', () => { + const actionTypes = [ + actions.fetching.error, + actions.fetching.received, + ].map(action => action.toString()); + testActionTypes(actionTypes, dataKey); }); - - describe('getRoles', () => { - it('dispatches got_roles action and subsequent actions after fetching role that allows gradebook', () => { - const expectedActions = [ - { type: GOT_ROLES, canUserViewGradebook: true, courseId: course1Id }, - { type: STARTED_FETCHING_GRADES }, - { type: STARTED_FETCHING_TRACKS }, - { type: STARTED_FETCHING_COHORTS }, - { type: STARTED_FETCHING_ASSIGNMENT_TYPES }, - ]; - const store = mockStore(); - axiosMock.onGet(rolesUrl) - .replyOnce( - 200, - JSON.stringify(makeRoleListObj([course1StaffRole, course2DummyRole], false)), - ); - - return store.dispatch(getRoles(course1Id, urlParams)).then(() => { - expect(store.getActions()).toEqual(expectedActions); - }); - }); - - it('dispatches got_roles action and other actions after fetching irrelevent roles but user is global staff', () => { - const expectedActions = [ - { type: GOT_ROLES, canUserViewGradebook: true, courseId: course1Id }, - { type: STARTED_FETCHING_GRADES }, - { type: STARTED_FETCHING_TRACKS }, - { type: STARTED_FETCHING_COHORTS }, - { type: STARTED_FETCHING_ASSIGNMENT_TYPES }, - ]; - const store = mockStore(); - - axiosMock.onGet(rolesUrl) - .replyOnce( - 200, - JSON.stringify(makeRoleListObj([course1DummyRole, course2DummyRole], true)), - ); - - return store.dispatch(getRoles(course1Id, urlParams)).then(() => { - expect(store.getActions()).toEqual(expectedActions); - }); - }); - - it('dispatches got_roles action and no other actions after fetching role that disallows gradebook', () => { - const expectedActions = [ - { - type: GOT_ROLES, canUserViewGradebook: false, courseId: course1Id, - }, - ]; - const store = mockStore(); - - axiosMock.onGet(rolesUrl) - .replyOnce( - 200, - JSON.stringify(makeRoleListObj([course1DummyRole, course2StaffRole], false)), - ); - - return store.dispatch(getRoles(course1Id, urlParams)).then(() => { - expect(store.getActions()).toEqual(expectedActions); - }); - }); - - it('dispatches got_roles action and no other actions after fetching empty roles', () => { - const expectedActions = [ - { type: GOT_ROLES, canUserViewGradebook: false, courseId: course1Id }, - ]; - const store = mockStore(); - - axiosMock.onGet(rolesUrl) - .replyOnce( - 200, - JSON.stringify(makeRoleListObj([], false)), - ); - - return store.dispatch(getRoles(course1Id, urlParams)).then(() => { - expect(store.getActions()).toEqual(expectedActions); - }); - }); - - it('dispatches got_roles action and other actions after fetching empty roles but user is global staff', () => { - const expectedActions = [ - { type: GOT_ROLES, canUserViewGradebook: true, courseId: course1Id }, - { type: STARTED_FETCHING_GRADES }, - { type: STARTED_FETCHING_TRACKS }, - { type: STARTED_FETCHING_COHORTS }, - { type: STARTED_FETCHING_ASSIGNMENT_TYPES }, - ]; - const store = mockStore(); - - axiosMock.onGet(rolesUrl) - .replyOnce( - 200, - JSON.stringify(makeRoleListObj([], true)), - ); - - return store.dispatch(getRoles(course1Id, urlParams)).then(() => { - expect(store.getActions()).toEqual(expectedActions); - }); - }); - - it('dispatches error action after getting an error when trying to get roles', () => { - const expectedActions = [ - { type: ERROR_FETCHING_ROLES }, - ]; - const store = mockStore(); - - axiosMock.onGet(rolesUrl).replyOnce(400); - - return store.dispatch(getRoles(course1Id, urlParams)).then(() => { - expect(store.getActions()).toEqual(expectedActions); - }); + describe('actions provided', () => { + describe('fecthing actions', () => { + test('error action', () => testAction(actions.fetching.error)); + test('received action', () => testAction(actions.fetching.received)); }); }); }); diff --git a/src/data/actions/testUtils.js b/src/data/actions/testUtils.js new file mode 100644 index 0000000..d7cc86c --- /dev/null +++ b/src/data/actions/testUtils.js @@ -0,0 +1,48 @@ +/** + * testActionTypes(actionTypes, dataKey) + * Takes a list of actionTypes and a module dataKey, and verifies that + * * all actionTypes are unique + * * all actionTypes begin with the dataKey + * @param {string[]} actionTypes - list of action types + * @param {string} dataKey - module data key + */ +export const testActionTypes = (actionTypes, dataKey) => { + test('all types are unique', () => { + expect(actionTypes.length).toEqual((new Set(actionTypes)).size); + }); + test('all types begin with the module dataKey', () => { + actionTypes.forEach(type => { + expect(type.startsWith(dataKey)).toEqual(true); + }); + }); +}; + +/** + * testAction(action, args, expectedPayload) + * Multi-purpose action creator test function. + * If args/expectedPayload are passed, verifies that it produces the expected output when called + * with the given args. + * If none are passed, (for action creators with basic definition) it tests against a default + * test payload. + * @param {object} action - action creator object/method + * @param {[object]} args - optional payload argument + * @param {[object]} expectedPayload - optional expected payload. + */ +export const testAction = (action, args, expectedPayload) => { + const type = action.toString(); + if (args) { + if (Array.isArray(args)) { + expect(action(...args)).toEqual({ type, payload: expectedPayload }); + } else { + expect(action(args)).toEqual({ type, payload: expectedPayload }); + } + } else { + const payload = { test: 'PAYload' }; + expect(action(payload)).toEqual({ type, payload }); + } +}; + +export default { + testAction, + testActionTypes, +}; diff --git a/src/data/actions/tracks.js b/src/data/actions/tracks.js index 8d79461..ca618e0 100644 --- a/src/data/actions/tracks.js +++ b/src/data/actions/tracks.js @@ -1,39 +1,15 @@ -/* eslint-disable camelcase */ -import tracksSelectors from 'data/selectors/tracks'; -import { - STARTED_FETCHING_TRACKS, - GOT_TRACKS, - ERROR_FETCHING_TRACKS, -} from '../constants/actionTypes/tracks'; -import { fetchBulkUpgradeHistory } from './grades'; -import LmsApiService from '../services/LmsApiService'; +import { StrictDict } from 'utils'; +import { createActionFactory } from './utils'; -const { hasMastersTrack } = tracksSelectors; +export const dataKey = 'tracks'; +const createAction = createActionFactory(dataKey); -const startedFetchingTracks = () => ({ type: STARTED_FETCHING_TRACKS }); -const errorFetchingTracks = () => ({ type: ERROR_FETCHING_TRACKS }); -const gotTracks = (tracks) => ({ type: GOT_TRACKS, tracks }); - -const fetchTracks = (courseId) => ( - (dispatch) => { - dispatch(startedFetchingTracks()); - return LmsApiService.fetchTracks(courseId) - .then(({ data }) => data) - .then(({ course_modes }) => { - dispatch(gotTracks(course_modes)); - if (hasMastersTrack(course_modes)) { - dispatch(fetchBulkUpgradeHistory(courseId)); - } - }) - .catch(() => { - dispatch(errorFetchingTracks()); - }); - } -); - -export { - fetchTracks, - startedFetchingTracks, - gotTracks, - errorFetchingTracks, +const fetching = { + started: createAction('fetching/started'), + error: createAction('fetching/error'), + received: createAction('fetching/received'), }; + +export default StrictDict({ + fetching: StrictDict(fetching), +}); diff --git a/src/data/actions/tracks.test.js b/src/data/actions/tracks.test.js index 8d4ce41..6735bea 100644 --- a/src/data/actions/tracks.test.js +++ b/src/data/actions/tracks.test.js @@ -1,87 +1,20 @@ -import axios from 'axios'; -import configureMockStore from 'redux-mock-store'; -import MockAdapter from 'axios-mock-adapter'; -import thunk from 'redux-thunk'; +import actions, { dataKey } from './tracks'; +import { testAction, testActionTypes } from './testUtils'; -import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; -import { configuration } from '../../config'; -import { fetchTracks } from './tracks'; -import { - STARTED_FETCHING_TRACKS, - GOT_TRACKS, - ERROR_FETCHING_TRACKS, -} from '../constants/actionTypes/tracks'; - -const mockStore = configureMockStore([thunk]); - -jest.mock('@edx/frontend-platform/auth'); -const axiosMock = new MockAdapter(axios); -getAuthenticatedHttpClient.mockReturnValue(axios); -axios.isAccessTokenExpired = jest.fn(); -axios.isAccessTokenExpired.mockReturnValue(false); - -describe('actions', () => { - afterEach(() => { - axiosMock.reset(); +describe('actions.tracks', () => { + describe('action types', () => { + const actionTypes = [ + actions.fetching.error, + actions.fetching.started, + actions.fetching.received, + ].map(action => action.toString()); + testActionTypes(actionTypes, dataKey); }); - - describe('fetchTracks', () => { - const courseId = 'course-v1:edX+DemoX+Demo_Course'; - const trackUrl = `${configuration.LMS_BASE_URL}/api/enrollment/v1/course/${courseId}?include_expired=1`; - - it('dispatches success action after fetching tracks', () => { - const responseData = { - course_modes: [ - { - slug: 'audit', - name: 'Audit', - min_price: 0, - suggested_prices: '', - currency: 'usd', - expiration_datetime: null, - description: null, - sku: '68EFFFF', - bulk_sku: null, - }, - { - slug: 'verified', - name: 'Verified Certificate', - min_price: 100, - suggested_prices: '', - currency: 'usd', - expiration_datetime: '2021-05-04T18:08:12.644361Z', - description: null, - sku: '8CF08E5', - bulk_sku: 'A5B6DBE', - }], - }; - const expectedActions = [ - { type: STARTED_FETCHING_TRACKS }, - { type: GOT_TRACKS, tracks: responseData.course_modes }, - ]; - const store = mockStore(); - - axiosMock.onGet(trackUrl) - .replyOnce(200, JSON.stringify(responseData)); - - return store.dispatch(fetchTracks(courseId)).then(() => { - expect(store.getActions()).toEqual(expectedActions); - }); - }); - - it('dispatches failure action after fetching tracks', () => { - const expectedActions = [ - { type: STARTED_FETCHING_TRACKS }, - { type: ERROR_FETCHING_TRACKS }, - ]; - const store = mockStore(); - - axiosMock.onGet(trackUrl) - .replyOnce(500, JSON.stringify({})); - - return store.dispatch(fetchTracks(courseId)).then(() => { - expect(store.getActions()).toEqual(expectedActions); - }); + describe('actions provided', () => { + describe('fecthing actions', () => { + test('error action', () => testAction(actions.fetching.error)); + test('started action', () => testAction(actions.fetching.started)); + test('received action', () => testAction(actions.fetching.received)); }); }); }); diff --git a/src/data/actions/utils.js b/src/data/actions/utils.js index b51af20..91cec11 100644 --- a/src/data/actions/utils.js +++ b/src/data/actions/utils.js @@ -1,3 +1,5 @@ +import { createAction } from '@reduxjs/toolkit'; + const formatDateForDisplay = (inputDate) => { const options = { year: 'numeric', @@ -26,4 +28,12 @@ const sortAlphaAsc = (gradeRowA, gradeRowB) => { return 0; }; -export { sortAlphaAsc, formatDateForDisplay }; +const createActionFactory = (dataKey) => (actionKey, ...args) => ( + createAction(`${dataKey}/${actionKey}`, ...args) +); + +export { + createActionFactory, + sortAlphaAsc, + formatDateForDisplay, +}; diff --git a/src/data/constants/actionTypes/assignmentTypes.js b/src/data/constants/actionTypes/assignmentTypes.js deleted file mode 100644 index 203feed..0000000 --- a/src/data/constants/actionTypes/assignmentTypes.js +++ /dev/null @@ -1,11 +0,0 @@ -const STARTED_FETCHING_ASSIGNMENT_TYPES = 'STARTED_FETCHING_ASSIGNMENT_TYPES'; -const GOT_ASSIGNMENT_TYPES = 'GOT_ASSIGNMENT_TYPES'; -const ERROR_FETCHING_ASSIGNMENT_TYPES = 'ERROR_FETCHING_ASSIGNMENT_TYPES'; -const GOT_ARE_GRADES_FROZEN = 'GOT_ARE_GRADES_FROZEN'; - -export { - STARTED_FETCHING_ASSIGNMENT_TYPES, - GOT_ASSIGNMENT_TYPES, - ERROR_FETCHING_ASSIGNMENT_TYPES, - GOT_ARE_GRADES_FROZEN, -}; diff --git a/src/data/constants/actionTypes/cohorts.js b/src/data/constants/actionTypes/cohorts.js deleted file mode 100644 index 2027c26..0000000 --- a/src/data/constants/actionTypes/cohorts.js +++ /dev/null @@ -1,9 +0,0 @@ -const STARTED_FETCHING_COHORTS = 'STARTED_FETCHING_COHORTS'; -const GOT_COHORTS = 'GOT_COHORTS'; -const ERROR_FETCHING_COHORTS = 'ERROR_FETCHING_COHORTS'; - -export { - STARTED_FETCHING_COHORTS, - GOT_COHORTS, - ERROR_FETCHING_COHORTS, -}; diff --git a/src/data/constants/actionTypes/config.js b/src/data/constants/actionTypes/config.js deleted file mode 100644 index af5c684..0000000 --- a/src/data/constants/actionTypes/config.js +++ /dev/null @@ -1,3 +0,0 @@ -const GOT_BULK_MANAGEMENT_CONFIG = 'GOT_BULK_MANAGEMENT_CONFIG'; - -export default GOT_BULK_MANAGEMENT_CONFIG; diff --git a/src/data/constants/actionTypes/filters.js b/src/data/constants/actionTypes/filters.js deleted file mode 100644 index 1d7d097..0000000 --- a/src/data/constants/actionTypes/filters.js +++ /dev/null @@ -1,10 +0,0 @@ -const INITIALIZE_FILTERS = 'INITIALIZE_FILTERS'; -const RESET_FILTERS = 'RESET_FILTERS'; -const UPDATE_ASSIGNMENT_FILTER = 'UPDATE_ASSIGNMENT_FILTER'; -const UPDATE_ASSIGNMENT_LIMITS = 'UPDATE_ASSIGNMENT_LIMITS'; -const UPDATE_COURSE_GRADE_LIMITS = 'UPDATE_COURSE_GRADE_LIMITS'; -const UPDATE_INCLUDE_COURSE_ROLE_MEMBERS = 'UPDATE_INCLUDE_COURSE_ROLE_MEMBERS'; -export { - INITIALIZE_FILTERS, RESET_FILTERS, UPDATE_ASSIGNMENT_FILTER, - UPDATE_ASSIGNMENT_LIMITS, UPDATE_COURSE_GRADE_LIMITS, UPDATE_INCLUDE_COURSE_ROLE_MEMBERS, -}; diff --git a/src/data/constants/actionTypes/grades.js b/src/data/constants/actionTypes/grades.js deleted file mode 100644 index 561cc33..0000000 --- a/src/data/constants/actionTypes/grades.js +++ /dev/null @@ -1,59 +0,0 @@ -const STARTED_FETCHING_GRADES = 'STARTED_FETCHING_GRADES'; -const FINISHED_FETCHING_GRADES = 'FINISHED_FETCHING_GRADES'; -const ERROR_FETCHING_GRADES = 'ERROR_FETCHING_GRADES'; -const GOT_GRADES = 'GOT_GRADES'; -const DONE_VIEWING_ASSIGNMENT = 'DONE_VIEWING_ASSIGNMENT'; -const GOT_GRADE_OVERRIDE_HISTORY = 'GOT_GRADE_OVERRIDE_HISTORY'; -const ERROR_FETCHING_GRADE_OVERRIDE_HISTORY = 'ERROR_FETCHING_GRADE_OVERRIDE_HISTORY'; - -const FILTER_SELECTED = 'FILTER_SELECTED'; -const GRADE_OVERRIDE = 'GRADE_OVERRIDE'; -const REPORT_DOWNLOADED = 'REPORT_DOWNLOADED'; -const UPLOAD_OVERRIDE = 'UPLOAD_OVERRIDE'; -const UPLOAD_OVERRIDE_ERROR = 'UPLOAD_OVERRIDE_ERROR'; - -const GRADE_UPDATE_REQUEST = 'GRADE_UPDATE_REQUEST'; -const GRADE_UPDATE_SUCCESS = 'GRADE_UPDATE_SUCCESS'; -const GRADE_UPDATE_FAILURE = 'GRADE_UPDATE_FAILURE'; - -const TOGGLE_GRADE_FORMAT = 'TOGGLE_GRADE_FORMAT'; -const FILTER_BY_ASSIGNMENT_TYPE = 'FILTER_BY_ASSIGNMENT_TYPE'; -const CLOSE_BANNER = 'CLOSE_BANNER'; -const OPEN_BANNER = 'OPEN_BANNER'; - -const START_UPLOAD = 'START_UPLOAD'; -const UPLOAD_COMPLETE = 'UPLOAD_COMPLETE'; -const UPLOAD_ERR = 'UPLOAD_ERR'; -const GOT_BULK_HISTORY = 'GOT_BULK_HISTORY'; -const BULK_HISTORY_ERR = 'BULK_HISTORY_ERR'; -const BULK_GRADE_REPORT_DOWNLOADED = 'BULK_GRADE_REPORT_DOWNLOADED'; -const INTERVENTION_REPORT_DOWNLOADED = 'INTERVENTION_REPORT_DOWNLOADED'; - -export { - STARTED_FETCHING_GRADES, - FINISHED_FETCHING_GRADES, - ERROR_FETCHING_GRADES, - GOT_GRADES, - GRADE_UPDATE_REQUEST, - GRADE_UPDATE_SUCCESS, - GRADE_UPDATE_FAILURE, - TOGGLE_GRADE_FORMAT, - FILTER_BY_ASSIGNMENT_TYPE, - OPEN_BANNER, - CLOSE_BANNER, - START_UPLOAD, - UPLOAD_COMPLETE, - UPLOAD_ERR, - GOT_BULK_HISTORY, - BULK_HISTORY_ERR, - DONE_VIEWING_ASSIGNMENT, - GOT_GRADE_OVERRIDE_HISTORY, - ERROR_FETCHING_GRADE_OVERRIDE_HISTORY, - FILTER_SELECTED, - GRADE_OVERRIDE, - REPORT_DOWNLOADED, - UPLOAD_OVERRIDE, - UPLOAD_OVERRIDE_ERROR, - BULK_GRADE_REPORT_DOWNLOADED, - INTERVENTION_REPORT_DOWNLOADED, -}; diff --git a/src/data/constants/actionTypes/roles.js b/src/data/constants/actionTypes/roles.js deleted file mode 100644 index 2e00503..0000000 --- a/src/data/constants/actionTypes/roles.js +++ /dev/null @@ -1,7 +0,0 @@ -const GOT_ROLES = 'GOT_ROLES'; -const ERROR_FETCHING_ROLES = 'ERROR_FETCHING_ROLES'; - -export { - GOT_ROLES, - ERROR_FETCHING_ROLES, -}; diff --git a/src/data/constants/actionTypes/tracks.js b/src/data/constants/actionTypes/tracks.js deleted file mode 100644 index aa89e98..0000000 --- a/src/data/constants/actionTypes/tracks.js +++ /dev/null @@ -1,9 +0,0 @@ -const STARTED_FETCHING_TRACKS = 'STARTED_FETCHING_TRACKS'; -const GOT_TRACKS = 'GOT_TRACKS'; -const ERROR_FETCHING_TRACKS = 'ERROR_FETCHING_TRACKS'; - -export { - STARTED_FETCHING_TRACKS, - GOT_TRACKS, - ERROR_FETCHING_TRACKS, -}; diff --git a/src/data/reducers/assignmentTypes.js b/src/data/reducers/assignmentTypes.js index 1edc840..0845453 100644 --- a/src/data/reducers/assignmentTypes.js +++ b/src/data/reducers/assignmentTypes.js @@ -1,9 +1,4 @@ -import { - STARTED_FETCHING_ASSIGNMENT_TYPES, - ERROR_FETCHING_ASSIGNMENT_TYPES, - GOT_ASSIGNMENT_TYPES, - GOT_ARE_GRADES_FROZEN, -} from '../constants/actionTypes/assignmentTypes'; +import actions from '../actions/assignmentTypes'; const initialState = { results: [], @@ -11,30 +6,30 @@ const initialState = { errorFetching: false, }; -const assignmentTypes = (state = initialState, action) => { - switch (action.type) { - case GOT_ASSIGNMENT_TYPES: - return { - ...state, - results: action.assignmentTypes, - errorFetching: false, - finishedFetching: true, - }; - case STARTED_FETCHING_ASSIGNMENT_TYPES: +const assignmentTypes = (state = initialState, { type, payload }) => { + switch (type) { + case actions.fetching.started.toString(): return { ...state, startedFetching: true, }; - case ERROR_FETCHING_ASSIGNMENT_TYPES: + case actions.fetching.received.toString(): + return { + ...state, + results: payload, + errorFetching: false, + finishedFetching: true, + }; + case actions.fetching.error.toString(): return { ...state, finishedFetching: true, errorFetching: true, }; - case GOT_ARE_GRADES_FROZEN: + case actions.gotGradesFrozen.toString(): return { ...state, - areGradesFrozen: action.areGradesFrozen, + areGradesFrozen: payload, errorFetching: false, finishedFetching: true, }; @@ -43,4 +38,5 @@ const assignmentTypes = (state = initialState, action) => { } }; +export { initialState }; export default assignmentTypes; diff --git a/src/data/reducers/assignmentTypes.test.js b/src/data/reducers/assignmentTypes.test.js index 7224d17..893cd4e 100644 --- a/src/data/reducers/assignmentTypes.test.js +++ b/src/data/reducers/assignmentTypes.test.js @@ -1,68 +1,71 @@ -import assignmentTypes from './assignmentTypes'; -import { - STARTED_FETCHING_ASSIGNMENT_TYPES, - ERROR_FETCHING_ASSIGNMENT_TYPES, - GOT_ASSIGNMENT_TYPES, - GOT_ARE_GRADES_FROZEN, -} from '../constants/actionTypes/assignmentTypes'; +import assignmentTypes, { initialState } from './assignmentTypes'; +import actions from '../actions/assignmentTypes'; -const initialState = { - results: [], - startedFetching: false, - errorFetching: false, +const testingState = { + ...initialState, + results: ['Exam', 'Homework'], + arbitraryField: 'arbitrary', }; -const assignmentTypesData = ['Exam', 'Homework']; - describe('assignmentTypes reducer', () => { it('has initial state', () => { - expect(assignmentTypes(undefined, {})).toEqual(initialState); + expect( + assignmentTypes(undefined, {}), + ).toEqual(initialState); }); - it('updates fetch assignmentTypes request state', () => { - const expected = { - ...initialState, - startedFetching: true, - }; - expect(assignmentTypes(undefined, { - type: STARTED_FETCHING_ASSIGNMENT_TYPES, - })).toEqual(expected); + describe('handling actions.fetching.started', () => { + it('sets startedFetching=true', () => { + const expected = { + ...testingState, + startedFetching: true, + }; + expect( + assignmentTypes(testingState, actions.fetching.started()), + ).toEqual(expected); + }); }); - it('updates fetch assignmentTypes success state', () => { - const expected = { - ...initialState, - results: assignmentTypesData, - errorFetching: false, - finishedFetching: true, - }; - expect(assignmentTypes(undefined, { - type: GOT_ASSIGNMENT_TYPES, - assignmentTypes: assignmentTypesData, - })).toEqual(expected); + describe('handling actions.fetching.received', () => { + it('loads the results and sets finishedFetching=true and errorFetching=false', () => { + const expectedResults = ['Exam']; + const expected = { + ...testingState, + results: expectedResults, + errorFetching: false, + finishedFetching: true, + }; + expect( + assignmentTypes(testingState, actions.fetching.received(expectedResults)), + ).toEqual(expected); + }); }); - it('updates fetch assignmentTypes failure state', () => { - const expected = { - ...initialState, - errorFetching: true, - finishedFetching: true, - }; - expect(assignmentTypes(undefined, { - type: ERROR_FETCHING_ASSIGNMENT_TYPES, - })).toEqual(expected); + describe('handling actions.fetching.error', () => { + it('sets errorFetching=true and finishedFetching=true', () => { + const expected = { + ...testingState, + errorFetching: true, + finishedFetching: true, + }; + expect( + assignmentTypes(testingState, actions.fetching.error()), + ).toEqual(expected); + }); }); - it('updates areGradesFrozen success state', () => { - const expected = { - ...initialState, - errorFetching: false, - finishedFetching: true, - areGradesFrozen: true, - }; - expect(assignmentTypes(undefined, { - type: GOT_ARE_GRADES_FROZEN, - areGradesFrozen: true, - })).toEqual(expected); + describe('handling actions.gotGradesFrozen', () => { + it('loads areGradesFrozen and sets errorFetching=false and finishedFetching=true', () => { + const expectedAreGradesFrozen = true; + const expected = { + ...testingState, + errorFetching: false, + finishedFetching: true, + areGradesFrozen: expectedAreGradesFrozen, + }; + expect( + assignmentTypes(testingState, actions.gotGradesFrozen(expectedAreGradesFrozen)), + ).toEqual(expected); + }); }); }); diff --git a/src/data/reducers/cohorts.js b/src/data/reducers/cohorts.js index 7acc22f..cabbfbf 100644 --- a/src/data/reducers/cohorts.js +++ b/src/data/reducers/cohorts.js @@ -1,8 +1,4 @@ -import { - STARTED_FETCHING_COHORTS, - ERROR_FETCHING_COHORTS, - GOT_COHORTS, -} from '../constants/actionTypes/cohorts'; +import actions from '../actions/cohorts'; const initialState = { results: [], @@ -12,19 +8,19 @@ const initialState = { const cohorts = (state = initialState, action) => { switch (action.type) { - case GOT_COHORTS: - return { - ...state, - results: action.cohorts, - finishedFetching: true, - errorFetching: false, - }; - case STARTED_FETCHING_COHORTS: + case actions.fetching.started.toString(): return { ...state, startedFetching: true, }; - case ERROR_FETCHING_COHORTS: + case actions.fetching.received.toString(): + return { + ...state, + results: action.payload, + finishedFetching: true, + errorFetching: false, + }; + case actions.fetching.error.toString(): return { ...state, finishedFetching: true, @@ -35,4 +31,5 @@ const cohorts = (state = initialState, action) => { } }; +export { initialState }; export default cohorts; diff --git a/src/data/reducers/cohorts.test.js b/src/data/reducers/cohorts.test.js index 44de984..9ea4609 100644 --- a/src/data/reducers/cohorts.test.js +++ b/src/data/reducers/cohorts.test.js @@ -1,70 +1,61 @@ -import cohorts from './cohorts'; -import { - STARTED_FETCHING_COHORTS, - ERROR_FETCHING_COHORTS, - GOT_COHORTS, -} from '../constants/actionTypes/cohorts'; - -const initialState = { - results: [], - startedFetching: false, - errorFetching: false, -}; +import cohorts, { initialState } from './cohorts'; +import actions from '../actions/cohorts'; const cohortsData = [ - { - assignment_type: 'manual', - group_id: null, - id: 1, - name: 'default_group', - user_count: 2, - user_partition_id: null, - }, - { - assignment_type: 'auto', - group_id: null, - id: 2, - name: 'auto_group', - user_count: 5, - user_partition_id: null, - }]; + { arbitraryCohortField: 'some data' }, + { anotherArbitraryCohortField: 'some data' }, +]; + +const testingState = { + ...initialState, + results: cohortsData, + arbitraryField: 'arbitrary', +}; describe('cohorts reducer', () => { it('has initial state', () => { - expect(cohorts(undefined, {})).toEqual(initialState); + expect( + cohorts(undefined, {}), + ).toEqual(initialState); }); - it('updates fetch cohorts request state', () => { - const expected = { - ...initialState, - startedFetching: true, - }; - expect(cohorts(undefined, { - type: STARTED_FETCHING_COHORTS, - })).toEqual(expected); + describe('handling actions.fetching.started', () => { + it('sets startedFetching=true', () => { + const expected = { + ...testingState, + startedFetching: true, + }; + expect( + cohorts(testingState, actions.fetching.started()), + ).toEqual(expected); + }); }); - it('updates fetch cohorts success state', () => { - const expected = { - ...initialState, - results: cohortsData, - errorFetching: false, - finishedFetching: true, - }; - expect(cohorts(undefined, { - type: GOT_COHORTS, - cohorts: cohortsData, - })).toEqual(expected); + describe('handling actions.fetching.received', () => { + it('loads results and sets finishedFetching=true and errorFetching=false', () => { + const newCohortData = [{ newResultFields: 'recieved data' }]; + const expected = { + ...testingState, + results: newCohortData, + errorFetching: false, + finishedFetching: true, + }; + expect( + cohorts(testingState, actions.fetching.received(newCohortData)), + ).toEqual(expected); + }); }); - it('updates fetch cohorts failure state', () => { - const expected = { - ...initialState, - errorFetching: true, - finishedFetching: true, - }; - expect(cohorts(undefined, { - type: ERROR_FETCHING_COHORTS, - })).toEqual(expected); + describe('handling actions.fetching.error', () => { + it('sets finishedFetching=true and errorFetching=true', () => { + const expected = { + ...testingState, + errorFetching: true, + finishedFetching: true, + }; + expect( + cohorts(testingState, actions.fetching.error()), + ).toEqual(expected); + }); }); }); diff --git a/src/data/reducers/config.js b/src/data/reducers/config.js index e98685a..9858d84 100644 --- a/src/data/reducers/config.js +++ b/src/data/reducers/config.js @@ -1,15 +1,18 @@ -import GOT_BULK_MANAGEMENT_CONFIG from '../constants/actionTypes/config'; +import actions from '../actions/config'; -const reducer = (state = {}, action) => { +const initialState = {}; + +const reducer = (state = initialState, action) => { switch (action.type) { - case GOT_BULK_MANAGEMENT_CONFIG: + case actions.gotBulkManagementConfig.toString(): return { ...state, - bulkManagementAvailable: action.data, + bulkManagementAvailable: action.payload, }; default: return state; } }; +export { initialState }; export default reducer; diff --git a/src/data/reducers/config.test.js b/src/data/reducers/config.test.js new file mode 100644 index 0000000..9cb2b69 --- /dev/null +++ b/src/data/reducers/config.test.js @@ -0,0 +1,25 @@ +import config, { initialState } from './config'; +import actions from '../actions/config'; + +const testingState = { + abitraryField: 'abitrary', +}; + +describe('config reducer', () => { + it('has initial state', () => { + expect( + config(undefined, {}), + ).toEqual(initialState); + }); + + it('loads bulkManagementAvailable from payload', () => { + const expectedBulkManagementAvailable = true; + const expected = { + ...testingState, + bulkManagementAvailable: expectedBulkManagementAvailable, + }; + expect( + config(testingState, actions.gotBulkManagementConfig(expectedBulkManagementAvailable)), + ).toEqual(expected); + }); +}); diff --git a/src/data/reducers/filters.js b/src/data/reducers/filters.js index e8a71cc..832ba6c 100644 --- a/src/data/reducers/filters.js +++ b/src/data/reducers/filters.js @@ -1,86 +1,83 @@ -import filterSelectors from 'data/selectors/filters'; -import { GOT_GRADES, FILTER_BY_ASSIGNMENT_TYPE } from '../constants/actionTypes/grades'; -import { - INITIALIZE_FILTERS, - UPDATE_ASSIGNMENT_FILTER, - UPDATE_ASSIGNMENT_LIMITS, - UPDATE_COURSE_GRADE_LIMITS, - RESET_FILTERS, - UPDATE_INCLUDE_COURSE_ROLE_MEMBERS, -} from '../constants/actionTypes/filters'; +import selectors from 'data/selectors'; +import actions from '../actions/filters'; +import gradeActions from '../actions/grades'; import initialFilters from '../constants/filters'; -const { getAssignmentsFromResultsSubstate, chooseRelevantAssignmentData } = filterSelectors; const initialState = {}; -const reducer = (state = initialState, action) => { - switch (action.type) { - case FILTER_BY_ASSIGNMENT_TYPE: +const reducer = (state = initialState, { type: actionType, payload }) => { + switch (actionType) { + case actions.initialize.toString(): return { ...state, - assignmentType: action.filterType, + ...payload, + }; + case actions.reset.toString(): { + const result = { ...state }; + payload.forEach((filterName) => { + result[filterName] = initialFilters[filterName]; + }); + return result; + } + case actions.update.assignment.toString(): + return { + ...state, + assignment: payload, + }; + case actions.update.assignmentLimits.toString(): + return { + ...state, + assignmentGradeMin: payload.minGrade, + assignmentGradeMax: payload.maxGrade, + }; + case actions.update.assignmentType.toString(): + return { + ...state, + assignmentType: payload, assignment: ( - action.filterType !== '' - && (state.assignment || {}).type !== action.filterType) - ? '' : state.assignment, + ( + payload !== '' + && (state.assignment || {}).type !== payload + ) ? '' : state.assignment + ), }; - case INITIALIZE_FILTERS: + case actions.update.courseGradeLimits.toString(): return { ...state, - ...action.data, + courseGradeMin: payload.courseGradeMin, + courseGradeMax: payload.courseGradeMax, }; - case GOT_GRADES: { + case actions.update.includeCourseRoleMembers.toString(): + return { + ...state, + includeCourseRoleMembers: payload, + }; + case gradeActions.fetching.received.toString(): { const { assignment } = state; const { id, type } = assignment || {}; - if (!type) { - const relevantAssignment = getAssignmentsFromResultsSubstate(action.grades) - .map(chooseRelevantAssignmentData) - .find(assig => assig.id === id); + if (id && !type) { + const { relevantAssignmentDataFromResults } = selectors.filters; + const relevantAssignment = relevantAssignmentDataFromResults( + payload.grades, + id, + ); return { ...state, - track: action.track, - cohort: action.cohort, + track: payload.track, + cohort: payload.cohort, assignment: relevantAssignment, }; } return { ...state, - track: action.track, - cohort: action.cohort, + track: payload.track, + cohort: payload.cohort, }; } - case RESET_FILTERS: { - const result = { ...state }; - action.filterNames.forEach((filterName) => { - result[filterName] = initialFilters[filterName]; - }); - return result; - } - case UPDATE_ASSIGNMENT_FILTER: - return { - ...state, - assignment: action.data, - }; - case UPDATE_ASSIGNMENT_LIMITS: - return { - ...state, - assignmentGradeMin: action.data.minGrade, - assignmentGradeMax: action.data.maxGrade, - }; - case UPDATE_COURSE_GRADE_LIMITS: - return { - ...state, - courseGradeMin: action.data.courseGradeMin, - courseGradeMax: action.data.courseGradeMax, - }; - case UPDATE_INCLUDE_COURSE_ROLE_MEMBERS: - return { - ...state, - includeCourseRoleMembers: action.data.includeCourseRoleMembers, - }; default: return state; } }; +export { initialState }; export default reducer; diff --git a/src/data/reducers/filters.test.js b/src/data/reducers/filters.test.js new file mode 100644 index 0000000..b7c3c9e --- /dev/null +++ b/src/data/reducers/filters.test.js @@ -0,0 +1,201 @@ +import selectors from 'data/selectors'; +import filter, { initialState } from './filters'; +import actions from '../actions/filters'; +import gradeActions from '../actions/grades'; +import initialFilters from '../constants/filters'; + +jest.mock('data/selectors', () => ({ + __esModule: true, + default: { + filters: { + relevantAssignmentDataFromResults: jest.fn(), + }, + }, +})); + +const expectedFilterType = 'homework'; +const expectedAssignmentId = 'assignment 1'; +const expectedAssignment = { + type: expectedFilterType, + id: expectedAssignmentId, +}; +const testingState = { + ...initialState, + arbitraryField: 'arbirary', + assignmentType: 'exam', + assignment: { ...expectedAssignment }, +}; + +describe('filter reducer', () => { + it('has initial state', () => { + expect( + filter(undefined, {}), + ).toEqual(initialState); + }); + + describe('handling actions.initialize', () => { + it('replaces all passed fields', () => { + const payload = { + assignment: { ...expectedAssignment }, + assignmentType: expectedFilterType, + track: 'verified', + cohort: 5, + assignmentGradeMin: 50, + assignmentGradeMax: 100, + courseGradeMin: 50, + courseGradeMax: 100, + includeCourseRoleMembers: true, + }; + const action = { type: actions.initialize.toString(), payload }; + expect(filter(testingState, action)).toEqual({ ...testingState, ...payload }); + }); + it('only replaces passed fields', () => { + const payload = { otherField: 'some data' }; + const action = { type: actions.initialize.toString(), payload }; + expect(filter(testingState, action)).toEqual({ ...testingState, ...payload }); + }); + }); + + describe('handling actions.reset', () => { + it('resets the all attribute existed in filter to initial filter', () => { + expect( + filter(testingState, actions.reset(Object.keys(initialFilters))), + ).toEqual({ ...testingState, ...initialFilters }); + }); + it('only resets keys passed in the action', () => { + const payload = ['assignment', 'assignmentType']; + expect(filter(testingState, actions.reset(payload))).toEqual({ + ...testingState, + [payload[0]]: initialFilters[payload[0]], + [payload[1]]: initialFilters[payload[1]], + }); + }); + }); + + describe('handle actions.update.assignment', () => { + it('loads assignment from payload', () => { + expect( + filter(testingState, actions.update.assignment(expectedAssignment)), + ).toEqual({ ...testingState, assignment: expectedAssignment }); + }); + }); + + describe('handle actions.update.assignmentLimits', () => { + it('loads assignmentGrade[Min/Max] from payload [min/max]grade', () => { + const expectedMinGrade = 50; + const expectedMaxGrade = 100; + expect( + filter(testingState, actions.update.assignmentLimits({ + minGrade: expectedMinGrade, + maxGrade: expectedMaxGrade, + })), + ).toEqual({ + ...testingState, + assignmentGradeMin: expectedMinGrade, + assignmentGradeMax: expectedMaxGrade, + }); + }); + }); + + describe('handle actions.update.assignmentType', () => { + const action = actions.update.assignmentType; + describe('new non-empty type', () => { + const newType = 'new ASsignment TYpe'; + it('loads assignmentType and clears assignment', () => { + expect( + filter(testingState, action(newType)), + ).toEqual({ + ...testingState, + assignmentType: newType, + assignment: '', + }); + }); + }); + describe('empty string type', () => { + it('does not clear assignment if the type is empty', () => { + expect( + filter(testingState, action('')), + ).toEqual({ ...testingState, assignmentType: '' }); + }); + }); + describe('matching type', () => { + it('does not clear the assignment if the type still matches the assignment', () => { + expect( + filter(testingState, action(testingState.assignment.type)), + ).toEqual({ + ...testingState, + assignmentType: testingState.assignment.type, + }); + }); + }); + }); + + describe('handling actions.update.courseGradeLimits', () => { + it('updates courseGrade[Min/Max]', () => { + const payload = { + courseGradeMin: 20, + courseGradeMax: 70, + }; + expect( + filter(initialState, actions.update.courseGradeLimits(payload)), + ).toEqual({ ...initialState, ...payload }); + }); + }); + + describe('handling actions.update.includeCourseRoleMembers', () => { + it('updates includeCourseRoleMembers from payload', () => { + const includeCourseRoleMembers = true; + expect( + filter(initialState, actions.update.includeCourseRoleMembers(includeCourseRoleMembers)), + ).toEqual({ ...initialState, includeCourseRoleMembers }); + }); + }); + + describe('handling gradeActions.fetching.received', () => { + const mockSelector = (val) => { + selectors.filters.relevantAssignmentDataFromResults.mockImplementation( + (...args) => ({ args, val }), + ); + }; + const assignmentId = 'fake ID'; + const action = gradeActions.fetching.received; + const payload = { + cohort: 'aCohoRT', + track: 'ATRacK', + grades: 'someGrades', + }; + const relevantAssignment = { relevant: 'assignment' }; + describe('with non-typed assignment filter', () => { + const state = { ...testingState, assignment: { id: assignmentId } }; + it('loads relevant assignment data by id with track and cohort from payload', () => { + mockSelector(relevantAssignment); + expect(filter(state, action(payload))).toEqual({ + ...state, + cohort: payload.cohort, + track: payload.track, + assignment: { args: [payload.grades, assignmentId], val: relevantAssignment }, + }); + }); + }); + describe('with empty assignment filter', () => { + const state = { ...testingState, assignment: '' }; + it('loads cohort and track from payload', () => { + expect(filter(state, action(payload))).toEqual({ + ...state, + cohort: payload.cohort, + track: payload.track, + }); + }); + }); + describe('with typed assignment filter', () => { + const state = { ...testingState, assignment: { id: assignmentId, type: 'homework' } }; + it('loads cohort and track from payload', () => { + expect(filter(state, action(payload))).toEqual({ + ...state, + cohort: payload.cohort, + track: payload.track, + }); + }); + }); + }); +}); diff --git a/src/data/reducers/grades.js b/src/data/reducers/grades.js index b55f09c..cf52ee9 100644 --- a/src/data/reducers/grades.js +++ b/src/data/reducers/grades.js @@ -1,19 +1,5 @@ -import { - STARTED_FETCHING_GRADES, - ERROR_FETCHING_GRADES, - GOT_GRADES, - TOGGLE_GRADE_FORMAT, - FILTER_BY_ASSIGNMENT_TYPE, - OPEN_BANNER, - CLOSE_BANNER, - START_UPLOAD, - UPLOAD_COMPLETE, - UPLOAD_ERR, - GOT_BULK_HISTORY, - DONE_VIEWING_ASSIGNMENT, - GOT_GRADE_OVERRIDE_HISTORY, - ERROR_FETCHING_GRADE_OVERRIDE_HISTORY, -} from '../constants/actionTypes/grades'; +import actions from '../actions/grades'; +import filterActions from '../actions/filters'; const initialState = { results: [], @@ -41,23 +27,53 @@ const initialState = { filteredUsersCount: 0, }; -const grades = (state = initialState, action) => { - switch (action.type) { - case GOT_GRADES: +const grades = (state = initialState, { type, payload }) => { + switch (type) { + case actions.banner.open.toString(): return { ...state, - results: action.grades, - headings: action.headings, - finishedFetching: true, - errorFetching: false, - prevPage: action.prev, - nextPage: action.next, - showSpinner: false, - courseId: action.courseId, - totalUsersCount: action.totalUsersCount, - filteredUsersCount: action.filteredUsersCount, + showSuccess: true, }; - case DONE_VIEWING_ASSIGNMENT: { + case actions.banner.close.toString(): + return { + ...state, + showSuccess: false, + }; + case actions.bulkHistory.received.toString(): + return { + ...state, + bulkManagement: { + ...state.bulkManagement, + history: payload, + }, + }; + case actions.csvUpload.started.toString(): { + const { errorMessages, uploadSuccess, ...rest } = state.bulkManagement; + return { + ...state, + showSpinner: true, + bulkManagement: rest, + }; + } + case actions.csvUpload.finished.toString(): + return { + ...state, + showSpinner: false, + bulkManagement: { + ...state.bulkManagement, + uploadSuccess: true, + }, + }; + case actions.csvUpload.error.toString(): + return { + ...state, + showSpinner: false, + bulkManagement: { + ...state.bulkManagement, + ...payload, + }, + }; + case actions.doneViewingAssignment.toString(): { const { gradeOverrideHistoryResults, gradeOverrideCurrentEarnedAllOverride, @@ -72,96 +88,63 @@ const grades = (state = initialState, action) => { } = state; return rest; } - case GOT_GRADE_OVERRIDE_HISTORY: - return { - ...state, - gradeOverrideHistoryResults: action.overrideHistory, - gradeOverrideCurrentEarnedAllOverride: action.currentEarnedAllOverride, - gradeOverrideCurrentPossibleAllOverride: action.currentPossibleAllOverride, - gradeOverrideCurrentEarnedGradedOverride: action.currentEarnedGradedOverride, - gradeOverrideCurrentPossibleGradedOverride: action.currentPossibleGradedOverride, - gradeOriginalEarnedAll: action.originalGradeEarnedAll, - gradeOriginalPossibleAll: action.originalGradePossibleAll, - gradeOriginalEarnedGraded: action.originalGradeEarnedGraded, - gradeOriginalPossibleGraded: action.originalGradePossibleGraded, - overrideHistoryError: '', - }; - - case ERROR_FETCHING_GRADE_OVERRIDE_HISTORY: - return { - ...state, - finishedFetchingOverrideHistory: true, - overrideHistoryError: action.errorMessage, - }; - - case STARTED_FETCHING_GRADES: + case actions.fetching.started.toString(): return { ...state, startedFetching: true, finishedFetching: false, showSpinner: true, }; - case ERROR_FETCHING_GRADES: + case actions.fetching.error.toString(): return { ...state, finishedFetching: true, errorFetching: true, }; - case TOGGLE_GRADE_FORMAT: - return { - ...state, - gradeFormat: action.formatType, - }; - case FILTER_BY_ASSIGNMENT_TYPE: - return { - ...state, - selectedAssignmentType: action.filterType, - headings: action.headings, - }; - case OPEN_BANNER: - return { - ...state, - showSuccess: true, - }; - case CLOSE_BANNER: - return { - ...state, - showSuccess: false, - }; - case START_UPLOAD: { - const { errorMessages, uploadSuccess, ...rest } = state.bulkManagement; - return { - ...state, - showSpinner: true, - bulkManagement: rest, - }; - } - case UPLOAD_COMPLETE: { + case actions.fetching.received.toString(): return { ...state, + results: payload.grades, + headings: payload.headings, + finishedFetching: true, + errorFetching: false, + prevPage: payload.prev, + nextPage: payload.next, showSpinner: false, - bulkManagement: { - ...state.bulkManagement, - uploadSuccess: true, - }, + courseId: payload.courseId, + totalUsersCount: payload.totalUsersCount, + filteredUsersCount: payload.filteredUsersCount, }; - } - case UPLOAD_ERR: + case actions.overrideHistory.received.toString(): return { ...state, - showSpinner: false, - bulkManagement: { - ...state.bulkManagement, - ...action.data, - }, + gradeOverrideHistoryResults: payload.overrideHistory, + gradeOverrideCurrentEarnedAllOverride: payload.currentEarnedAllOverride, + gradeOverrideCurrentPossibleAllOverride: payload.currentPossibleAllOverride, + gradeOverrideCurrentEarnedGradedOverride: payload.currentEarnedGradedOverride, + gradeOverrideCurrentPossibleGradedOverride: payload.currentPossibleGradedOverride, + gradeOriginalEarnedAll: payload.originalGradeEarnedAll, + gradeOriginalPossibleAll: payload.originalGradePossibleAll, + gradeOriginalEarnedGraded: payload.originalGradeEarnedGraded, + gradeOriginalPossibleGraded: payload.originalGradePossibleGraded, + overrideHistoryError: '', }; - case GOT_BULK_HISTORY: + case actions.overrideHistory.error.toString(): return { ...state, - bulkManagement: { - ...state.bulkManagement, - history: action.data, - }, + finishedFetchingOverrideHistory: true, + overrideHistoryError: payload, + }; + case actions.toggleGradeFormat.toString(): + return { + ...state, + gradeFormat: payload, + }; + case filterActions.update.assignmentType.toString(): + return { + ...state, + selectedAssignmentType: payload.filterType, + headings: payload.headings, }; default: return state; diff --git a/src/data/reducers/grades.test.js b/src/data/reducers/grades.test.js index e91faa7..8ea0a8c 100644 --- a/src/data/reducers/grades.test.js +++ b/src/data/reducers/grades.test.js @@ -1,178 +1,248 @@ import grades, { initialGradesState as initialState } from './grades'; -import { - STARTED_FETCHING_GRADES, - ERROR_FETCHING_GRADES, - GOT_GRADES, - TOGGLE_GRADE_FORMAT, - FILTER_BY_ASSIGNMENT_TYPE, - OPEN_BANNER, - ERROR_FETCHING_GRADE_OVERRIDE_HISTORY, -} from '../constants/actionTypes/grades'; +import actions from '../actions/grades'; +import filterActions from '../actions/filters'; -const courseId = 'course-v1:edX+DemoX+Demo_Course'; const headingsData = [ { name: 'exam' }, { name: 'homework2' }, ]; -const gradesData = [ - { - course_id: courseId, - email: 'user1@example.com', - username: 'user1', - user_id: 1, - percent: 0.5, - letter_grade: null, - section_breakdown: [ - { - subsection_name: 'Demo Course Overview', - score_earned: 0, - score_possible: 0, - percent: 0, - displayed_value: '0.00', - grade_description: '(0.00/0.00)', - }, - { - subsection_name: 'Example Week 1: Getting Started', - score_earned: 1, - score_possible: 1, - percent: 1, - displayed_value: '1.00', - grade_description: '(0.00/0.00)', - }, - ], + +const testingState = { + ...initialState, + bulkManagement: { + errorMessages: 'some error message', + uploadSuccess: false, }, - { - course_id: courseId, - email: 'user22@example.com', - username: 'user22', - user_id: 22, - percent: 0, - letter_grade: null, - section_breakdown: [ - { - subsection_name: 'Demo Course Overview', - score_earned: 0, - score_possible: 0, - percent: 0, - displayed_value: '0.00', - grade_description: '(0.00/0.00)', - }, - { - subsection_name: 'Example Week 1: Getting Started', - score_earned: 1, - score_possible: 1, - percent: 0, - displayed_value: '0.00', - grade_description: '(0.00/0.00)', - }, - ], - }]; + arbitraryField: 'abitrary', +}; describe('grades reducer', () => { it('has initial state', () => { - expect(grades(undefined, {})).toEqual(initialState); + expect( + grades(undefined, {}), + ).toEqual(initialState); }); - it('updates fetch grades request state', () => { - const expected = { - ...initialState, - startedFetching: true, - showSpinner: true, - }; - expect(grades(undefined, { - type: STARTED_FETCHING_GRADES, - })).toEqual(expected); - }); + describe('action handlers', () => { + describe('actions.banner.open', () => { + it('sets showSuccess to true', () => { + expect( + grades(undefined, actions.banner.open()), + ).toEqual({ ...initialState, showSuccess: true }); + }); + }); + describe('actions.banner.close', () => { + it('set showSuccess to false', () => { + expect( + grades(undefined, actions.banner.close()), + ).toEqual({ ...initialState, showSuccess: false }); + }); + }); - it('updates fetch grades success state', () => { - const expectedPrev = 'testPrevUrl'; - const expectedNext = 'testNextUrl'; - const expectedTrack = 'verified'; - const expectedCohortId = 2; - const expected = { - ...initialState, - results: gradesData, - headings: headingsData, - errorFetching: false, - finishedFetching: true, - prevPage: expectedPrev, - nextPage: expectedNext, - showSpinner: false, - courseId, - totalUsersCount: 4, - filteredUsersCount: 2, - }; - expect(grades(undefined, { - type: GOT_GRADES, - grades: gradesData, - headings: headingsData, - prev: expectedPrev, - next: expectedNext, - track: expectedTrack, - cohort: expectedCohortId, - showSpinner: true, - courseId, - totalUsersCount: 4, - filteredUsersCount: 2, - })).toEqual(expected); - }); + describe('actions.bulkHistory.received', () => { + it('loads payload into bulkManagement.history', () => { + const history = 'HIstory'; + expect( + grades(testingState, actions.bulkHistory.received(history)), + ).toEqual({ + ...testingState, + bulkManagement: { ...testingState.bulkManagement, history }, + }); + }); + }); - it('updates toggle grade format state success', () => { - const formatTypeData = 'percent'; - const expected = { - ...initialState, - gradeFormat: formatTypeData, - }; - expect(grades(undefined, { - type: TOGGLE_GRADE_FORMAT, - formatType: formatTypeData, - })).toEqual(expected); - }); + describe('actions.csvUpload.started', () => { + it( + 'sets showSpinner=true and removes errorMessages and uploadSuccess from bulkManagement', + () => { + const { + errorMessages, + uploadSuccess, + ...expectedBulkManagement + } = testingState.bulkManagement; + expect( + grades(testingState, actions.csvUpload.started()), + ).toEqual({ + ...testingState, + showSpinner: true, + bulkManagement: expectedBulkManagement, + }); + }, + ); + }); + describe('handling actions.csvUpload.finished', () => { + it('sets showSpinner=false and sets bulkManagement.uploadSuccess=true', () => { + expect( + grades(testingState, actions.csvUpload.finished()), + ).toEqual({ + ...testingState, + showSpinner: false, + bulkManagement: { ...testingState.bulkManagement, uploadSuccess: true }, + }); + }); + }); + describe('handling actions.csvUpload.error', () => { + it('loads errorMessage to bulkManagement from payload and sets showSpinner=false', () => { + const errorMessage = 'This is a new error message'; + expect( + grades(testingState, actions.csvUpload.error({ + errorMessage, + uploadSuccess: false, + })), + ).toEqual({ + ...testingState, + showSpinner: false, + bulkManagement: { errorMessage, ...testingState.bulkManagement }, + }); + }); + }); - it('updates filter columns state success', () => { - const expectedHeadings = headingsData; - const expected = { - ...initialState, - headings: expectedHeadings, - }; - expect(grades(undefined, { - type: FILTER_BY_ASSIGNMENT_TYPE, - headings: expectedHeadings, - })).toEqual(expected); - }); + describe('actions.doneViewingAssignment', () => { + it('removes gradeOverride* and gradeOriginal* from existing state', () => { + const { + gradeOverrideHistoryResults, + gradeOverrideCurrentEarnedAllOverride, + gradeOverrideCurrentPossibleAllOverride, + gradeOverrideCurrentEarnedGradedOverride, + gradeOverrideCurrentPossibleGradedOverride, + gradeOriginalEarnedAll, + gradeOriginalPossibleAll, + gradeOriginalEarnedGraded, + gradeOriginalPossibleGraded, + ...expected + } = testingState; + expect( + grades(testingState, actions.doneViewingAssignment()), + ).toEqual(expected); + }); + }); - it('updates update_banner state success', () => { - const expectedShowSuccess = true; - const expected = { - ...initialState, - showSuccess: expectedShowSuccess, - }; - expect(grades(undefined, { - type: OPEN_BANNER, - })).toEqual(expected); - }); + describe('actions.fetching.started', () => { + it('sets startedFetching and showSpinner to true', () => { + expect( + grades(testingState, actions.fetching.started()), + ).toEqual({ + ...testingState, + startedFetching: true, + showSpinner: true, + }); + }); + }); + describe('actions.fetching.received', () => { + it( + 'loads payload and sets finishedFetching:true, errorFetching:false, showSpinner:false', + () => { + const payload = { + courseId: 'course-v1:edX+DemoX+Demo_Course', + headings: 'some Headings', + prev: 'testPrevUrl', + next: 'testNextUrl', + track: 'verified', + cohortId: 2, + totalUsersCount: 4, + filteredUsersCount: 2, + assignmentType: 'Homework', + grades: { somethingArbitrary: 'some data' }, + }; + expect( + grades(testingState, actions.fetching.received(payload)), + ).toEqual({ + ...testingState, + results: payload.grades, + headings: payload.headings, + prevPage: payload.prev, + nextPage: payload.next, + courseId: payload.courseId, + totalUsersCount: payload.totalUsersCount, + filteredUsersCount: payload.filteredUsersCount, + errorFetching: false, + finishedFetching: true, + showSpinner: false, + }); + }, + ); + }); + describe('actions.fetching.error', () => { + it('sets finishedFetching and errorFetching to true', () => { + expect( + grades(testingState, actions.fetching.error()), + ).toEqual({ + ...testingState, + errorFetching: true, + finishedFetching: true, + }); + }); + }); - it('updates fetch grades failure state', () => { - const expected = { - ...initialState, - errorFetching: true, - finishedFetching: true, - }; - expect(grades(undefined, { - type: ERROR_FETCHING_GRADES, - })).toEqual(expected); - }); + describe('actions.overrideHistory.received', () => { + it('loads payload and clears overrideHistoryError', () => { + const payload = { + overrideHistory: true, + currentEarnedAllOverride: false, + currentPossibleAllOverride: 'red', + currentEarnedGradedOverride: 'green', + currentPossibleGradedOverride: 'blue', + originalGradeEarnedAll: 'other', + originalGradePossibleAll: 'sparrow', + originalGradeEarnedGraded: 'crow', + originalGradePossibleGraded: 'raven', + }; + expect( + grades(testingState, actions.overrideHistory.received(payload)), + ).toEqual({ + ...testingState, + gradeOverrideHistoryResults: payload.overrideHistory, + gradeOverrideCurrentEarnedAllOverride: payload.currentEarnedAllOverride, + gradeOverrideCurrentPossibleAllOverride: payload.currentPossibleAllOverride, + gradeOverrideCurrentEarnedGradedOverride: payload.currentEarnedGradedOverride, + gradeOverrideCurrentPossibleGradedOverride: payload.currentPossibleGradedOverride, + gradeOriginalEarnedAll: payload.originalGradeEarnedAll, + gradeOriginalPossibleAll: payload.originalGradePossibleAll, + gradeOriginalEarnedGraded: payload.originalGradeEarnedGraded, + gradeOriginalPossibleGraded: payload.originalGradePossibleGraded, + overrideHistoryError: '', + }); + }); + }); + describe('actions.overrideHistory.error', () => { + it( + 'sets finishedFetchingOverrideHistory=true and loads overrideHistoryError from payload', + () => { + const errorMessage = 'This is the error message'; + expect( + grades(testingState, actions.overrideHistory.error(errorMessage)), + ).toEqual({ + ...testingState, + finishedFetchingOverrideHistory: true, + overrideHistoryError: errorMessage, + }); + }, + ); + }); - it('updates fetch grade override history failure state', () => { - const errorMessage = 'This is the error message'; - const expected = { - ...initialState, - finishedFetchingOverrideHistory: true, - overrideHistoryError: errorMessage, - }; - expect(grades(undefined, { - type: ERROR_FETCHING_GRADE_OVERRIDE_HISTORY, - errorMessage, - })).toEqual(expected); + describe('handling actions.toggleGradeFormat', () => { + it('updates grade format attribute', () => { + const formatTypeData = 'percent'; + expect( + grades(undefined, actions.toggleGradeFormat(formatTypeData)), + ).toEqual({ ...initialState, gradeFormat: formatTypeData }); + }); + }); + + describe('handling filterActions.update.assignmentType', () => { + it('loads assignmentType and headings from the payload', () => { + const expectedSelectedAssignmentType = 'selected assignment type'; + expect( + grades(testingState, filterActions.update.assignmentType({ + headings: headingsData, + filterType: expectedSelectedAssignmentType, + })), + ).toEqual({ + ...testingState, + selectedAssignmentType: expectedSelectedAssignmentType, + headings: headingsData, + }); + }); + }); }); }); diff --git a/src/data/reducers/roles.js b/src/data/reducers/roles.js index 4342275..118520a 100644 --- a/src/data/reducers/roles.js +++ b/src/data/reducers/roles.js @@ -1,7 +1,4 @@ -import { - GOT_ROLES, - ERROR_FETCHING_ROLES, -} from '../constants/actionTypes/roles'; +import actions from '../actions/roles'; const initialState = { canUserViewGradebook: null, @@ -9,12 +6,12 @@ const initialState = { const roles = (state = initialState, action) => { switch (action.type) { - case GOT_ROLES: + case actions.fetching.received.toString(): return { ...state, - canUserViewGradebook: action.canUserViewGradebook, + canUserViewGradebook: action.payload, }; - case ERROR_FETCHING_ROLES: + case actions.fetching.error.toString(): return { ...state, canUserViewGradebook: false, @@ -24,4 +21,5 @@ const roles = (state = initialState, action) => { } }; +export { initialState }; export default roles; diff --git a/src/data/reducers/roles.test.js b/src/data/reducers/roles.test.js index 290f9e5..6dff3e0 100644 --- a/src/data/reducers/roles.test.js +++ b/src/data/reducers/roles.test.js @@ -1,47 +1,38 @@ -import roles from './roles'; -import { - ERROR_FETCHING_ROLES, - GOT_ROLES, -} from '../constants/actionTypes/roles'; +import roles, { initialState } from './roles'; +import actions from '../actions/roles'; -const initialState = { - canUserViewGradebook: null, +const testingState = { + ...initialState, + arbitraryField: 'arbitrary', }; -describe('tracks reducer', () => { +describe('roles reducer', () => { it('has initial state', () => { - expect(roles(undefined, {})).toEqual(initialState); + expect( + roles(undefined, {}), + ).toEqual(initialState); }); - it('updates canUserViewGradebook to true', () => { - const expected = { - ...initialState, - canUserViewGradebook: true, - }; - expect(roles(undefined, { - type: GOT_ROLES, - canUserViewGradebook: true, - })).toEqual(expected); + describe('handling actions.received', () => { + it('updates canUserViewGradebook to the received payload', () => { + const expectedCanUserViewGradebook = true; + expect( + roles(testingState, actions.fetching.received(expectedCanUserViewGradebook)), + ).toEqual({ + ...testingState, + canUserViewGradebook: expectedCanUserViewGradebook, + }); + }); }); - it('updates canUserViewGradebook to false', () => { - const expected = { - ...initialState, - canUserViewGradebook: false, - }; - expect(roles(undefined, { - type: GOT_ROLES, - canUserViewGradebook: false, - })).toEqual(expected); - }); - - it('updates fetch roles failure state', () => { - const expected = { - ...initialState, - canUserViewGradebook: false, - }; - expect(roles(undefined, { - type: ERROR_FETCHING_ROLES, - })).toEqual(expected); + describe('handling actions.errorFetching', () => { + it('sets canUserViewGradebook to false', () => { + expect( + roles(testingState, actions.fetching.error()), + ).toEqual({ + ...testingState, + canUserViewGradebook: false, + }); + }); }); }); diff --git a/src/data/reducers/tracks.js b/src/data/reducers/tracks.js index 399af8b..fcafaae 100644 --- a/src/data/reducers/tracks.js +++ b/src/data/reducers/tracks.js @@ -1,8 +1,4 @@ -import { - STARTED_FETCHING_TRACKS, - ERROR_FETCHING_TRACKS, - GOT_TRACKS, -} from '../constants/actionTypes/tracks'; +import actions from '../actions/tracks'; const initialState = { results: [], @@ -12,19 +8,19 @@ const initialState = { const tracks = (state = initialState, action) => { switch (action.type) { - case GOT_TRACKS: - return { - ...state, - results: action.tracks, - errorFetching: false, - finishedFetching: true, - }; - case STARTED_FETCHING_TRACKS: + case actions.fetching.started.toString(): return { ...state, startedFetching: true, }; - case ERROR_FETCHING_TRACKS: + case actions.fetching.received.toString(): + return { + ...state, + results: action.payload, + errorFetching: false, + finishedFetching: true, + }; + case actions.fetching.error.toString(): return { ...state, finishedFetching: true, @@ -35,4 +31,5 @@ const tracks = (state = initialState, action) => { } }; +export { initialState }; export default tracks; diff --git a/src/data/reducers/tracks.test.js b/src/data/reducers/tracks.test.js index a3ae57c..cf371bb 100644 --- a/src/data/reducers/tracks.test.js +++ b/src/data/reducers/tracks.test.js @@ -1,76 +1,58 @@ -import tracks from './tracks'; -import { - STARTED_FETCHING_TRACKS, - ERROR_FETCHING_TRACKS, - GOT_TRACKS, -} from '../constants/actionTypes/tracks'; - -const initialState = { - results: [], - startedFetching: false, - errorFetching: false, -}; +import tracks, { initialState } from './tracks'; +import actions from '../actions/tracks'; const tracksData = [ - { - slug: 'audit', - name: 'Audit', - min_price: 0, - suggested_prices: '', - currency: 'usd', - expiration_datetime: null, - description: null, - sku: '68EFFFF', - bulk_sku: null, - }, - { - slug: 'verified', - name: 'Verified Certificate', - min_price: 100, - suggested_prices: '', - currency: 'usd', - expiration_datetime: '2021-05-04T18:08:12.644361Z', - description: null, - sku: '8CF08E5', - bulk_sku: 'A5B6DBE', - }]; + { someArbitraryField: 'arbitrary data' }, + { anotherArbitraryField: 'more arbitrary data' }, +]; + +const testingState = { + ...initialState, + results: tracksData, + arbitraryField: 'arbitrary', +}; describe('tracks reducer', () => { it('has initial state', () => { - expect(tracks(undefined, {})).toEqual(initialState); + expect( + tracks(undefined, {}), + ).toEqual(initialState); }); - it('updates fetch tracks request state', () => { - const expected = { - ...initialState, - startedFetching: true, - }; - expect(tracks(undefined, { - type: STARTED_FETCHING_TRACKS, - })).toEqual(expected); + describe('handling actions.fetching.started', () => { + it('set start fetching to true. Preserve results if existed', () => { + expect( + tracks(testingState, actions.fetching.started()), + ).toEqual({ + ...testingState, + startedFetching: true, + }); + }); }); - it('updates fetch tracks success state', () => { - const expected = { - ...initialState, - results: tracksData, - errorFetching: false, - finishedFetching: true, - }; - expect(tracks(undefined, { - type: GOT_TRACKS, - tracks: tracksData, - })).toEqual(expected); + describe('handling actions.fetching.received', () => { + it('replace results then set finish fetching to true and error to false', () => { + const newTracksData = [{ receivedData: 'new data' }]; + expect( + tracks(testingState, actions.fetching.received(newTracksData)), + ).toEqual({ + ...testingState, + results: newTracksData, + errorFetching: false, + finishedFetching: true, + }); + }); }); - it('updates fetch tracks failure state', () => { - const expected = { - ...initialState, - errorFetching: true, - finishedFetching: true, - }; - expect(tracks(undefined, { - type: ERROR_FETCHING_TRACKS, - })).toEqual(expected); + describe('handling actions.fetching.error', () => { + it('set finish fetch and error to true. Preserve results if existed.', () => { + expect( + tracks(testingState, actions.fetching.error()), + ).toEqual({ + ...testingState, + errorFetching: true, + finishedFetching: true, + }); + }); }); }); diff --git a/src/data/selectors/assignmentTypes.js b/src/data/selectors/assignmentTypes.js index 4feff21..ddea2b2 100644 --- a/src/data/selectors/assignmentTypes.js +++ b/src/data/selectors/assignmentTypes.js @@ -1,6 +1,9 @@ -const selectors = { - areGradesFrozen: ({ assignmentTypes }) => assignmentTypes.areGradesFrozen, - allAssignmentTypes: ({ assignmentTypes }) => assignmentTypes.results, -}; +import { StrictDict } from 'utils'; -export default selectors; +const areGradesFrozen = ({ assignmentTypes }) => assignmentTypes.areGradesFrozen; +const allAssignmentTypes = ({ assignmentTypes }) => assignmentTypes.results; + +export default StrictDict({ + areGradesFrozen, + allAssignmentTypes, +}); diff --git a/src/data/selectors/assignmentTypes.test.js b/src/data/selectors/assignmentTypes.test.js index 6ef76d2..8e2d6ac 100644 --- a/src/data/selectors/assignmentTypes.test.js +++ b/src/data/selectors/assignmentTypes.test.js @@ -1,17 +1,21 @@ import selectors from './assignmentTypes'; -describe('areGradesFrozen', () => { - it('selects areGradesFrozen from state', () => { - const testValue = 'THX 1138'; - const areGradesFrozen = selectors.areGradesFrozen({ assignmentTypes: { areGradesFrozen: testValue } }); - expect(areGradesFrozen).toEqual(testValue); +describe('assignmentType selectors', () => { + describe('areGradesFrozen', () => { + it('selects areGradesFrozen from state', () => { + const testValue = 'THX 1138'; + expect( + selectors.areGradesFrozen({ assignmentTypes: { areGradesFrozen: testValue } }), + ).toEqual(testValue); + }); }); -}); -describe('allAssignmentTypes', () => { - it('returns assignment types', () => { - const testAssignmentTypes = ['assignment', 'labs']; - const allAssignmentTypes = selectors.allAssignmentTypes({ assignmentTypes: { results: testAssignmentTypes } }); - expect(allAssignmentTypes).toEqual(testAssignmentTypes); + describe('allAssignmentTypes', () => { + it('returns assignment types', () => { + const testAssignmentTypes = ['assignment', 'labs']; + expect( + selectors.allAssignmentTypes({ assignmentTypes: { results: testAssignmentTypes } }), + ).toEqual(testAssignmentTypes); + }); }); }); diff --git a/src/data/selectors/cohorts.js b/src/data/selectors/cohorts.js index 0f218c6..0b255b9 100644 --- a/src/data/selectors/cohorts.js +++ b/src/data/selectors/cohorts.js @@ -1,16 +1,34 @@ -const allCohorts = state => state.cohorts.results || []; +import { StrictDict } from 'utils'; +/** + * allCohorts(state) + * returns top-level cohorts results data + * @param {object} state - redux state + * @return {object[]} - list of cohort objects from fetch results + */ +const allCohorts = (state) => state.cohorts.results || []; +/** + * getCohortById(state, selectedCohortId) + * returns cohort with given id + * @param {object} state - redux state + * @param {number} selectedCohortId - id of cohort to return + * @return {object} cohort with given id. + */ const getCohortById = (state, selectedCohortId) => { - const cohort = allCohorts(state).find(coh => coh.id === selectedCohortId); + const cohort = allCohorts(state).find(({ id }) => id === selectedCohortId); return cohort; }; +/** + * getCohortNameById(state, selectedCohortId) + * @param {object} state - redux state + * @param {number} selectedCohortId - id of cohort whose name is requested + * @return {string} - name of cohort with the given id + */ const getCohortNameById = (state, selectedCohortId) => (getCohortById(state, selectedCohortId) || {}).name; -const selectors = { +export default StrictDict({ getCohortById, getCohortNameById, allCohorts, -}; - -export default selectors; +}); diff --git a/src/data/selectors/filters.js b/src/data/selectors/filters.js index 1888299..663f49c 100644 --- a/src/data/selectors/filters.js +++ b/src/data/selectors/filters.js @@ -1,12 +1,64 @@ +/* eslint-disable import/no-self-import */ +import { StrictDict } from 'utils'; +import * as module from './filters'; import simpleSelectorFactory from '../utils'; -const allFilters = (state) => state.filters || {}; +// Transformers +/** + * chooseRelevantAssignmentData(assignment) + * formats the assignment api data for an assignment object for consumption + * @param {object} assignment - assignment data to prepare + * @return {object} - formatted data ({ label, subsectionLabel, type, id }) + */ +export const chooseRelevantAssignmentData = ({ + label, + subsection_name: subsectionLabel, + category: type, + module_id: id, +}) => ({ + label, subsectionLabel, type, id, +}); -const getAssignmentsFromResultsSubstate = (results) => ( +/** + * getAssignmentsFromResultsSubstate(results) + * returns the section_breakdown of the first results entry + * defaulting to an empty list. + * @param {[object[]]} results - list of result entries from grades fetch + * @return {object} - section_breakdown of first grade entry + */ +export const getAssignmentsFromResultsSubstate = (results) => ( (results[0] || {}).section_breakdown || [] ); -const selectableAssignments = (state) => { +/** + * relevantAssignmentDataFromResults + * returns assignment info from grades results for the assignment with the given id + * @param {object} grades - grades fetch result + * @param {string} id - selected assignment id from assignment filter + * @return {object} assignment data with type, label, and subsectionLabel + */ +export const relevantAssignmentDataFromResults = (grades, id) => ( + module.getAssignmentsFromResultsSubstate(grades) + .map(module.chooseRelevantAssignmentData) + .find(assignment => assignment.id === id) +); + +// Selectors +/** + * allFilters(state) + * returns the top-level filter state. + * @param {object} state - redux state + * @return {object} - filter substate from redux state + */ +export const allFilters = (state) => state.filters || {}; + +/** + * selectableAssignments(state) + * @param {object} state - redux state + * @return {object[]} - list of selectable assignment objects, filtered if there is an + * assignmentType filter selected. + */ +export const selectableAssignments = (state) => { const selectedAssignmentType = allFilters(state).assignmentType; const needToFilter = selectedAssignmentType && selectedAssignmentType !== 'All'; const allAssignments = getAssignmentsFromResultsSubstate(state.grades.results); @@ -18,20 +70,16 @@ const selectableAssignments = (state) => { return allAssignments; }; -const chooseRelevantAssignmentData = ({ - label, - subsection_name: subsectionLabel, - category, - module_id: id, -}) => ({ - label, subsectionLabel, category, id, -}); - -const selectableAssignmentLabels = (state) => ( +/** + * Returns the relevant assignment data for all selectable assignments + * @param {object} state - redux state + * @return {object[]} - object of assignment data entries [({ label, subsectionLabel, type, id })] + */ +export const selectableAssignmentLabels = (state) => ( selectableAssignments(state).map(chooseRelevantAssignmentData) ); -const simpleSelectors = simpleSelectorFactory( +export const simpleSelectors = simpleSelectorFactory( ({ filters }) => filters, [ 'assignment', @@ -45,11 +93,24 @@ const simpleSelectors = simpleSelectorFactory( 'includeCourseRoleMembers', ], ); -const selectedAssignmentId = (state) => (simpleSelectors.assignment(state) || {}).id; -const selectedAssignmentLabel = (state) => (simpleSelectors.assignment(state) || {}).label; +/** + * Returns the id of the selected assignment filter + * @param {object} state - redux state + * @return {string} - assignment id + */ +export const selectedAssignmentId = (state) => (simpleSelectors.assignment(state) || {}).id; -const selectors = { +/** + * selectedAssignmentLabel(state) + * Returns the label of the selected assignment filter + * @param {object} state - redux state + * @return {string} - assignment label + */ +export const selectedAssignmentLabel = (state) => (simpleSelectors.assignment(state) || {}).label; + +export default StrictDict({ ...simpleSelectors, + relevantAssignmentDataFromResults, selectedAssignmentId, selectedAssignmentLabel, selectableAssignmentLabels, @@ -57,6 +118,4 @@ const selectors = { allFilters, chooseRelevantAssignmentData, getAssignmentsFromResultsSubstate, -}; - -export default selectors; +}); diff --git a/src/data/selectors/filters.test.js b/src/data/selectors/filters.test.js index 943465d..6a038d2 100644 --- a/src/data/selectors/filters.test.js +++ b/src/data/selectors/filters.test.js @@ -1,7 +1,10 @@ -import selectors from './filters'; +// import * in order to mock in-file references +import * as selectors from './filters'; +// import default export in order to test simpleSelectors not exported individually +import exportedSelectors from './filters'; const selectedAssignmentInfo = { - category: 'Homework', + type: 'Homework', id: 'block-v1:edX+type@sequential+block@abcde', label: 'HW 01', subsectionLabel: 'Example Week 1: Getting Started', @@ -63,75 +66,124 @@ const testState = { grades: gradesData, }; -describe('allFilters', () => { - it('selects all filters from state', () => { - const allFilters = selectors.allFilters(testState); - expect(allFilters).toEqual(filters); +describe('filters selectors', () => { + describe('chooseRelevantAssignmentData', () => { + it('maps label, subsection, category, and ID from assignment data', () => { + const assignmentData = selectors.chooseRelevantAssignmentData(sectionBreakdowns[0]); + expect(assignmentData).toEqual(selectedAssignmentInfo); + }); }); - it('returns an empty object when no filters are in state', () => { - const allFilters = selectors.allFilters({}); - expect(allFilters).toEqual({}); - }); -}); -describe('selectedAssignmentId', () => { - it('gets filtered assignment ID when available', () => { - const assignmentId = selectors.selectedAssignmentId(testState); - expect(assignmentId).toEqual(filters.assignment.id); + describe('getAssignmentsFromResultsSubstate', () => { + it('gets section breakdowns from state', () => { + const assignments = selectors.getAssignmentsFromResultsSubstate(gradesData.results); + expect(assignments).toEqual(sectionBreakdowns); + }); + it('returns an empty array when results are not supplied', () => { + const assignments = selectors.getAssignmentsFromResultsSubstate([]); + expect(assignments).toEqual([]); + }); + it('returns an empty array when section breakdowns are not supplied', () => { + const assignments = selectors.getAssignmentsFromResultsSubstate([{}]); + expect(assignments).toEqual([]); + }); }); - it('returns undefined when assignment ID unavailable', () => { - const assignmentId = selectors.selectedAssignmentId({ filters: { assignment: undefined } }); - expect(assignmentId).toEqual(undefined); - }); -}); -describe('selectedAssignmentLabel', () => { - it('gets filtered assignment label when available', () => { - const assignmentLabel = selectors.selectedAssignmentLabel(testState); - expect(assignmentLabel).toEqual(filters.assignment.label); - }); - it('returns undefined when assignment label is unavailable', () => { - const assignmentLabel = selectors.selectedAssignmentLabel({ filters: { assignment: undefined } }); - expect(assignmentLabel).toEqual(undefined); - }); -}); + describe('relevantAssignmentDataFromResults', () => { + it('grabs relevant assignment data from the grades substate with matching id', () => { + const ids = ['some', 'fake', 'ids']; + const grades = { gradeIds: ids }; -describe('selectableAssignmentLabels', () => { - it('gets assignment data for sections matching selected type filters', () => { - const selectableAssignmentLabels = selectors.selectableAssignmentLabels(testState); - expect(selectableAssignmentLabels).toEqual([filters.assignment]); + const getFromSubstate = selectors.getAssignmentsFromResultsSubstate; + selectors.getAssignmentsFromResultsSubstate = jest.fn( + ({ gradeIds }) => gradeIds, + ); + const mapper = selectors.chooseRelevantAssignmentData; + selectors.chooseRelevantAssignmentData = jest.fn((id) => ({ id })); + expect(selectors.relevantAssignmentDataFromResults(grades, ids[2])).toEqual( + { id: ids[2] }, + ); + selectors.getAssignmentsFromResultsSubstate = getFromSubstate; + selectors.chooseRelevantAssignmentData = mapper; + }); }); -}); -describe('selectableAssignments', () => { - it('returns all graded assignments when no assignment type filtering is applied', () => { - const selectableAssignments = selectors.selectableAssignments({ grades: gradesData, filters: noFilters }); - expect(selectableAssignments).toEqual(sectionBreakdowns); + describe('allFilters', () => { + it('selects all filters from state', () => { + const allFilters = selectors.allFilters(testState); + expect(allFilters).toEqual(filters); + }); + it('returns an empty object when no filters are in state', () => { + const allFilters = selectors.allFilters({}); + expect(allFilters).toEqual({}); + }); }); - it('gets assignments of the selected category when assignment type filtering is applied', () => { - const selectableAssignments = selectors.selectableAssignments(testState); - expect(selectableAssignments).toEqual([sectionBreakdowns[0]]); - }); -}); -describe('chooseRelevantAssignmentData', () => { - it('maps label, subsection, category, and ID from assignment data', () => { - const assignmentData = selectors.chooseRelevantAssignmentData(sectionBreakdowns[0]); - expect(assignmentData).toEqual(selectedAssignmentInfo); + describe('selectedAssignmentId', () => { + it('gets filtered assignment ID when available', () => { + const assignmentId = selectors.selectedAssignmentId(testState); + expect(assignmentId).toEqual(filters.assignment.id); + }); + it('returns undefined when assignment ID unavailable', () => { + const assignmentId = selectors.selectedAssignmentId({ filters: { assignment: undefined } }); + expect(assignmentId).toEqual(undefined); + }); }); -}); -describe('getAssignmentsFromResultsSubstate', () => { - it('gets section breakdowns from state', () => { - const assignments = selectors.getAssignmentsFromResultsSubstate(gradesData.results); - expect(assignments).toEqual(sectionBreakdowns); + describe('selectedAssignmentLabel', () => { + it('gets filtered assignment label when available', () => { + const assignmentLabel = selectors.selectedAssignmentLabel(testState); + expect(assignmentLabel).toEqual(filters.assignment.label); + }); + it('returns undefined when assignment label is unavailable', () => { + const assignmentLabel = selectors.selectedAssignmentLabel({ filters: { assignment: undefined } }); + expect(assignmentLabel).toEqual(undefined); + }); }); - it('returns an empty array when results are not supplied', () => { - const assignments = selectors.getAssignmentsFromResultsSubstate([]); - expect(assignments).toEqual([]); + + describe('selectableAssignmentLabels', () => { + it('gets assignment data for sections matching selected type filters', () => { + const selectableAssignmentLabels = selectors.selectableAssignmentLabels(testState); + expect(selectableAssignmentLabels).toEqual([filters.assignment]); + }); }); - it('returns an empty array when section breakdowns are not supplied', () => { - const assignments = selectors.getAssignmentsFromResultsSubstate([{}]); - expect(assignments).toEqual([]); + + describe('selectableAssignments', () => { + it('returns all graded assignments when no assignment type filtering is applied', () => { + const selectableAssignments = selectors.selectableAssignments({ grades: gradesData, filters: noFilters }); + expect(selectableAssignments).toEqual(sectionBreakdowns); + }); + it('gets assignments of the selected category when assignment type filtering is applied', () => { + const selectableAssignments = selectors.selectableAssignments(testState); + expect(selectableAssignments).toEqual([sectionBreakdowns[0]]); + }); + }); + + describe('simpleSelectors', () => { + const testVal = 'some Test Value'; + const testSimpleSelector = (key) => { + test(key, () => { + expect(exportedSelectors[key]({ filters: { [key]: testVal } })).toEqual(testVal); + }); + }; + testSimpleSelector('assignment'); + testSimpleSelector('assignmentGradeMax'); + testSimpleSelector('assignmentGradeMin'); + testSimpleSelector('assignmentType'); + testSimpleSelector('cohort'); + testSimpleSelector('courseGradeMax'); + testSimpleSelector('courseGradeMin'); + testSimpleSelector('track'); + testSimpleSelector('includeCourseRoleMembers'); + test('selectedAssignmentId', () => { + expect( + selectors.selectedAssignmentId({ filters: { assignment: { id: testVal } } }), + ).toEqual(testVal); + }); + test('selectedAssignmentLabel', () => { + expect( + selectors.selectedAssignmentLabel({ filters: { assignment: { label: testVal } } }), + ).toEqual(testVal); + }); }); }); diff --git a/src/data/selectors/grades.js b/src/data/selectors/grades.js index ec9e721..02504fe 100644 --- a/src/data/selectors/grades.js +++ b/src/data/selectors/grades.js @@ -1,42 +1,89 @@ +/* eslint-disable import/no-self-import */ +import { StrictDict } from 'utils'; import { formatDateForDisplay } from '../actions/utils'; import simpleSelectorFactory from '../utils'; import { EMAIL_HEADING, TOTAL_COURSE_GRADE_HEADING, USERNAME_HEADING } from '../constants/grades'; +import * as module from './grades'; -const getRowsProcessed = (data) => { - const { - processed_rows: processed, - saved_rows: saved, - total_rows: total, - } = data; - return { - total, - successfullyProcessed: saved, - failed: processed - saved, - skipped: total - processed, - }; -}; - -const transformHistoryEntry = ({ - modified, - original_filename: originalFilename, - data, - ...rest +export const getRowsProcessed = ({ + processed_rows: processed, + saved_rows: saved, + total_rows: total, }) => ({ - timeUploaded: formatDateForDisplay(new Date(modified)), - originalFilename, - summaryOfRowsProcessed: getRowsProcessed(data), - ...rest, + total, + successfullyProcessed: saved, + failed: processed - saved, + skipped: total - processed, }); -const bulkManagementHistory = ({ grades: { bulkManagement } }) => ( - (bulkManagement.history || []) +/** + * formatGradeOverrideForDisplay(historyArray) + * returns the grade override history results in display format. + * @param {object[]} historyArray - array of gradeOverrideHistoryResults + * @return {object[]} - display-formatted history results ({ date, grader, reason, adjustedGrade }) + */ +export const formatGradeOverrideForDisplay = historyArray => historyArray.map(item => ({ + date: formatDateForDisplay(new Date(item.history_date)), + grader: item.history_user, + reason: item.override_reason, + adjustedGrade: item.earned_graded_override, +})); + +export const minGrade = '0'; +export const maxGrade = '100'; + +/** + * formatMaxCourseGrade(percentGrade) + * Takes a percent grade and returns it unless it is equal to the max grade + * @param {string} percentGrade - grade percentage + * @return {string} percent grade or null + */ +export const formatMaxCourseGrade = (percentGrade) => ( + (percentGrade === maxGrade) ? null : percentGrade +); +/** + * formatMinCourseGrade(percentGrade) + * Takes a percent grade and returns it unless it is equal to the min grade + * @param {string} percentGrade - grade percentage + * @return {string} percent grade or null + */ +export const formatMinCourseGrade = (percentGrade) => ( + (percentGrade === minGrade) ? null : percentGrade ); -const bulkManagementHistoryEntries = (state) => ( - bulkManagementHistory(state).map(transformHistoryEntry) +/** + * formatMaxAssignmentGrade(percentGrade, options) + * Takes a percent grade and returns it unless it is equal to the max grade or + * the assignment id is set. + * @param {string} percentGrade - grade percentage + * @param {object} options - options object ({ assignmentId }); + * @return {string} percent grade or null + */ +export const formatMaxAssignmentGrade = (percentGrade, options) => ( + (percentGrade === maxGrade || !options.assignmentId) ? null : percentGrade ); -const headingMapper = (category, label = 'All') => { +/** + * formatMinAssignmentGrade(percentGrade, options) + * Takes a percent grade and returns it unless it is equal to the min grade or + * the assignment id is set. + * @param {string} percentGrade - grade percentage + * @param {object} options - options object ({ assignmentId }); + * @return {string} percent grade or null + */ +export const formatMinAssignmentGrade = (percentGrade, options) => ( + (percentGrade === minGrade || !options.assignmentId) ? null : percentGrade +); + +/** + * headingMapper(category, label='All') + * Takes category and label filters and returns a method that will take a section breakdown + * and return the appropriate table headings. + * @param {string} category - assignment filter type + * @param {string} label - assignment filter label + * @return {string[]} - list of table headers + */ +export const headingMapper = (category, label = 'All') => { const filters = { all: section => section.label, byCategory: section => section.label && section.category === category, @@ -45,66 +92,116 @@ const headingMapper = (category, label = 'All') => { let filter; if (label === 'All') { - filter = category === 'All' ? 'all' : 'byCategory'; + filter = category === 'All' ? filters.all : filters.byCategory; } else { - filter = 'byLabel'; + filter = filters.byLabel; } return (entry) => { if (entry) { - const results = [USERNAME_HEADING, EMAIL_HEADING]; - - const assignmentHeadings = entry - .filter(filters[filter]) - .map(s => s.label); - - const totals = [TOTAL_COURSE_GRADE_HEADING]; - - return results.concat(assignmentHeadings).concat(totals); + return [ + USERNAME_HEADING, + EMAIL_HEADING, + ...entry.filter(filter).map(s => s.label), + TOTAL_COURSE_GRADE_HEADING, + ]; } return []; }; }; -const getExampleSectionBreakdown = ({ grades }) => ( +/** + * transformHistoryEntry(rawEntry) + * Takes a raw bulkManagementHistory entry and formats it for consumption + * @param {object} rawEntry - raw history entry to transform + * @return {object} - transformed history entry + * ({ timeUploaded, originalFilename, summaryOfRowsProcessed, ... }) + */ +export const transformHistoryEntry = ({ + modified, + original_filename: originalFilename, + data, + ...rest +}) => ({ + timeUploaded: formatDateForDisplay(new Date(modified)), + originalFilename, + summaryOfRowsProcessed: module.getRowsProcessed(data), + ...rest, +}); + +// Selectors +/** + * allGrades(state) + * returns the top-level redux grades state. + * @param {object} state - redux state + * @return {object} - redux grades state + */ +export const allGrades = ({ grades: { results } }) => results; + +/** + * bulkImportError(state) + * returns the stringified bulkManagement import error messages. + * @param {object} state - redux state + * @return {string} - bulk import error messages joined into a display form + * (or empty string if there are none) + */ +export const bulkImportError = ({ grades: { bulkManagement } }) => ( + (!!bulkManagement && bulkManagement.errorMessages) + ? `Errors while processing: ${bulkManagement.errorMessages.join(', ')}` + : '' +); + +/** + * bulkManagementHistory(state) + * returns the bulkManagement history entries from the grades state + * @param {object} state - redux state + * @return {object[]} - list of bulkManagement history entries + */ +export const bulkManagementHistory = ({ grades: { bulkManagement } }) => ( + (bulkManagement.history || []) +); + +/** + * bulkManagementHistoryEntries(state) + * returns transformed history entries from bulkManagement grades data. + * @param {object} state - redux state + * @return {object[]} - list of transformed bulkManagement history entries + */ +export const bulkManagementHistoryEntries = (state) => ( + module.bulkManagementHistory(state).map(module.transformHistoryEntry) +); + +/** + * getExampleSectionBreakdown(state) + * returns section breakdown of first grades result. + * @param {object} state - redux state + * @return {object[]} - section breakdown of first grades result. + */ +export const getExampleSectionBreakdown = ({ grades }) => ( (grades.results[0] || {}).section_breakdown || [] ); -const composeFilters = (...predicates) => (percentGrade, options = {}) => predicates.reduce((accum, predicate) => { - if (predicate(percentGrade, options)) { - return null; - } - return accum; -}, percentGrade); +/** + * gradeOverrides(state) + * returns the gradeOverride history results + * @param {object} state - redux state + * @return {object[]} - grade override history result entries + */ +export const gradeOverrides = ({ grades }) => grades.gradeOverrideHistoryResults; -const percentGradeIsMax = percentGrade => ( - percentGrade === '100' -); - -const percentGradeIsMin = percentGrade => ( - percentGrade === '0' -); - -const assignmentIdIsDefined = (percentGrade, { assignmentId }) => ( - !assignmentId -); - -const formatMaxCourseGrade = composeFilters(percentGradeIsMax); -const formatMinCourseGrade = composeFilters(percentGradeIsMin); - -const formatMaxAssignmentGrade = composeFilters( - percentGradeIsMax, - assignmentIdIsDefined, -); - -const formatMinAssignmentGrade = composeFilters( - percentGradeIsMin, - assignmentIdIsDefined, +/** + * uploadSuccess(state) + * @param {object} state - redux state + * @return {bool} - is bulkManagement.uploadSuccess true? + */ +export const uploadSuccess = ({ grades: { bulkManagement } }) => ( + !!bulkManagement && bulkManagement.uploadSuccess ); const simpleSelectors = simpleSelectorFactory( ({ grades }) => grades, [ + 'courseId', 'filteredUsersCount', 'totalUsersCount', 'gradeFormat', @@ -117,30 +214,19 @@ const simpleSelectors = simpleSelectorFactory( ], ); -const allGrades = ({ grades: { results } }) => results; -const uploadSuccess = ({ grades: { bulkManagement } }) => (!!bulkManagement && bulkManagement.uploadSuccess); - -const bulkImportError = ({ grades: { bulkManagement } }) => ( - (!!bulkManagement && bulkManagement.errorMessages) - ? `Errors while processing: ${bulkManagement.errorMessages.join(', ')}` - : '' -); -const gradeOverrides = ({ grades }) => grades.gradeOverrideHistoryResults; - -const selectors = { +export default StrictDict({ bulkImportError, + formatGradeOverrideForDisplay, formatMinAssignmentGrade, formatMaxAssignmentGrade, formatMaxCourseGrade, formatMinCourseGrade, - getExampleSectionBreakdown, headingMapper, ...simpleSelectors, allGrades, - uploadSuccess, bulkManagementHistoryEntries, + getExampleSectionBreakdown, gradeOverrides, -}; - -export default selectors; + uploadSuccess, +}); diff --git a/src/data/selectors/grades.test.js b/src/data/selectors/grades.test.js index 331dd16..ce0c559 100644 --- a/src/data/selectors/grades.test.js +++ b/src/data/selectors/grades.test.js @@ -1,19 +1,9 @@ -import selectors from './grades'; +import { EMAIL_HEADING, TOTAL_COURSE_GRADE_HEADING, USERNAME_HEADING } from '../constants/grades'; +import { formatDateForDisplay } from '../actions/utils'; +import * as selectors from './grades'; +import exportedSelectors from './grades'; -const genericHistoryRow = { - id: 5, - class_name: 'bulk_grades.api.GradeCSVProcessor', - unique_id: 'course-v1:google+goog101+2018_spring', - operation: 'commit', - user: 'edx', - modified: '2019-07-16T20:25:46.700802Z', - original_filename: '', - data: { - total_rows: 5, - processed_rows: 3, - saved_rows: 3, - }, -}; +const { minGrade, maxGrade } = selectors; const genericResultsRows = [ { @@ -48,254 +38,260 @@ const genericResultsRows = [ }, ]; -describe('bulkImportError', () => { - it('returns an empty string when bulkManagement not run', () => { - const result = selectors.bulkImportError({ grades: { bulkManagement: null } }); - expect(result).toEqual(''); - }); - - it('returns an empty string when bulkManagement runs without error', () => { - const result = selectors.bulkImportError({ grades: { bulkManagement: { uploadSuccess: true } } }); - expect(result).toEqual(''); - }); - - it('returns error string when bulkManagement encounters an error', () => { - const errorMessages = ['error1', 'also error2']; - const expectedErrorString = `Errors while processing: ${errorMessages[0]}, ${errorMessages[1]}`; - const result = selectors.bulkImportError({ grades: { bulkManagement: { errorMessages } } }); - expect(result).toEqual(expectedErrorString); - }); -}); - -describe('grade formatters', () => { - const selectedAssignment = { assignmentId: 'block-v1:edX+type@sequential+block@abcde' }; - - describe('formatMinAssignmentGrade', () => { - const defaultGrade = '0'; - const modifiedGrade = '1'; - - it('passes numbers through when grade is not default (0) and assignment is supplied', () => { - const formattedMinAssignmentGrade = selectors.formatMinAssignmentGrade(modifiedGrade, selectedAssignment); - expect(formattedMinAssignmentGrade).toEqual(modifiedGrade); - }); - it('ignores grade when unmodified from default (0)', () => { - const formattedMinAssignmentGrade = selectors.formatMinAssignmentGrade(defaultGrade, selectedAssignment); - expect(formattedMinAssignmentGrade).toEqual(null); - }); - it('ignores grade when an assignment is not supplied', () => { - const formattedMinAssignmentGrade = selectors.formatMinAssignmentGrade(modifiedGrade, {}); - expect(formattedMinAssignmentGrade).toEqual(null); - }); - }); - - describe('formatMaxAssignmentGrade', () => { - const defaultGrade = '100'; - const modifiedGrade = '99'; - - it('passes numbers through when grade is not default (100) and assignment is supplied', () => { - const formattedMaxAssignmentGrade = selectors.formatMaxAssignmentGrade(modifiedGrade, selectedAssignment); - expect(formattedMaxAssignmentGrade).toEqual(modifiedGrade); - }); - it('ignores grade when unmodified from default (100)', () => { - const formattedMaxAssignmentGrade = selectors.formatMaxAssignmentGrade(defaultGrade, selectedAssignment); - expect(formattedMaxAssignmentGrade).toEqual(null); - }); - it('ignores grade when an assignment is not supplied', () => { - const formattedMaxAssignmentGrade = selectors.formatMaxAssignmentGrade(modifiedGrade, {}); - expect(formattedMaxAssignmentGrade).toEqual(null); - }); - }); - - describe('formatMinCourseGrade', () => { - const defaultGrade = '0'; - const modifiedGrade = '37'; - - it('passes numbers through when grade is not default (0) and assignment is supplied', () => { - const formattedMinGrade = selectors.formatMinCourseGrade(modifiedGrade, selectedAssignment); - expect(formattedMinGrade).toEqual(modifiedGrade); - }); - it('ignores grade when unmodified from default (0)', () => { - const formattedMinGrade = selectors.formatMinCourseGrade(defaultGrade, selectedAssignment); - expect(formattedMinGrade).toEqual(null); - }); - }); - - describe('formatMaxCourseGrade', () => { - const defaultGrade = '100'; - const modifiedGrade = '42'; - - it('passes numbers through when grade is not default (100) and assignment is supplied', () => { - const formattedMaxGrade = selectors.formatMaxCourseGrade(modifiedGrade, selectedAssignment); - expect(formattedMaxGrade).toEqual(modifiedGrade); - }); - it('ignores unmodified grades', () => { - const formattedMaxGrade = selectors.formatMaxCourseGrade(defaultGrade, selectedAssignment); - expect(formattedMaxGrade).toEqual(null); - }); - }); -}); - -describe('getExampleSectionBreakdown', () => { - const gradesData = { - next: null, - previous: null, - results: [ - { - section_breakdown: [ - { - subsection_name: 'Example Week 1: Getting Started', - score_earned: 1, - score_possible: 1, - percent: 1, - displayed_value: '1.00', - grade_description: '(0.00/0.00)', - }, - ], - }, - ], - }; - - it('returns an empty array when results are unavailable', () => { - const result = selectors.getExampleSectionBreakdown({ grades: { results: [{}] } }); - expect(result).toEqual([]); - }); - - it('returns an empty array when breakdowns are unavailable', () => { - const result = selectors.getExampleSectionBreakdown({ grades: { results: [{ foo: 'bar' }] } }); - expect(result).toEqual([]); - }); - - it('gets section breakdown when available', () => { - const result = selectors.getExampleSectionBreakdown({ grades: gradesData }); - expect(result).toEqual([{ - subsection_name: 'Example Week 1: Getting Started', - score_earned: 1, - score_possible: 1, - percent: 1, - displayed_value: '1.00', - grade_description: '(0.00/0.00)', - }]); - }); -}); - -describe('headingMapper', () => { - const expectedHeaders = (subsectionLabels) => (['Username', 'Email', ...subsectionLabels, 'Total Grade (%)']); - - it('creates headers for all assignments when no filtering is applied', () => { - const allSubsectionLabels = ['HW 01', 'HW 02', 'Lab 01']; - const headingMapper = selectors.headingMapper('All'); - const headers = headingMapper(genericResultsRows); - expect(headers).toEqual(expectedHeaders(allSubsectionLabels)); - }); - it('creates headers for only matching assignment types when type filter is applied', () => { - const homeworkHeaders = ['HW 01', 'HW 02']; - const headingMapper = selectors.headingMapper('Homework'); - const headers = headingMapper(genericResultsRows); - expect(headers).toEqual(expectedHeaders(homeworkHeaders)); - }); - it('creates headers for only matching assignment when label filter is applied', () => { - const homeworkHeader = ['HW 02']; - const headingMapper = selectors.headingMapper('Homework', 'HW 02'); - const headers = headingMapper(genericResultsRows); - expect(headers).toEqual(expectedHeaders(homeworkHeader)); - }); - it('returns an empty array when no entries are passed', () => { - const headingMapper = selectors.headingMapper('All'); - const headers = headingMapper(undefined); - expect(headers).toEqual([]); - }); -}); - -describe('simpleSelectors', () => { - const simpleSelectorState = { - grades: { - filteredUsersCount: 9000, - totalUsersCount: 9001, - gradeFormat: 'percent', - showSpinner: false, - gradeOverrideCurrentEarnedGradedOverride: null, - gradeOverrideHistoryError: null, - gradeOriginalEarnedGraded: null, - gradeOriginalPossibleGraded: null, - showSuccess: false, - }, - }; - - it('selects simple data by name from grades state', () => { - const expectedFilteredUsers = 9000; - const expectedTotalUsers = 9001; - const expectedGradeFormat = 'percent'; - - // the selector factory is already tested, this just exercises some of these mappings - expect(selectors.filteredUsersCount(simpleSelectorState)).toEqual(expectedFilteredUsers); - expect(selectors.totalUsersCount(simpleSelectorState)).toEqual(expectedTotalUsers); - expect(selectors.gradeFormat(simpleSelectorState)).toEqual(expectedGradeFormat); - }); -}); - -describe('uploadSuccess', () => { - it('shows an upload success when bulk management data returned and completed successfully', () => { - const uploadSuccess = selectors.uploadSuccess({ grades: { bulkManagement: { uploadSuccess: true } } }); - expect(uploadSuccess).toEqual(true); - }); - it('returns false when bulk management data not returned', () => { - const uploadSuccess = selectors.uploadSuccess({ grades: {} }); - expect(uploadSuccess).toEqual(false); - }); -}); - -describe('bulkManagementHistoryEntries', () => { - it('handles history being as-yet unloaded', () => { - const result = selectors.bulkManagementHistoryEntries({ grades: { bulkManagement: {} } }); - expect(result).toEqual([]); - }); - - it('formats dates for us', () => { - const result = selectors.bulkManagementHistoryEntries({ - grades: { - bulkManagement: { - history: [ - genericHistoryRow, - ], - }, - }, - }); - const [{ timeUploaded }] = result; - expect(timeUploaded).not.toMatch(/Z$/); - expect(timeUploaded).toContain(' at '); - }); - - const exerciseGetRowsProcessed = (input, expectation) => { - const result = selectors.bulkManagementHistoryEntries({ - grades: { - bulkManagement: { - history: [ - { ...genericHistoryRow, data: input }, - ], - }, - }, - }); - const [{ summaryOfRowsProcessed }] = result; - expect(summaryOfRowsProcessed).toEqual(expect.objectContaining(expectation)); - }; - - it('calculates skipped rows', () => { - exerciseGetRowsProcessed({ - total_rows: 100, - processed_rows: 10, +describe('grades selectors', () => { + // Transformers + describe('getRowsProcessed', () => { + const data = { + processed_rows: 20, saved_rows: 10, - }, { - skipped: 90, + total_rows: 50, + }; + expect(selectors.getRowsProcessed(data)).toEqual({ + total: data.total_rows, + successfullyProcessed: data.saved_rows, + failed: data.processed_rows - data.saved_rows, + skipped: data.total_rows - data.processed_rows, }); }); - it('calculates failures', () => { - exerciseGetRowsProcessed({ - total_rows: 10, - processed_rows: 100, - saved_rows: 10, - }, { - failed: 90, + describe('grade formatters', () => { + const selectedAssignment = { assignmentId: 'block-v1:edX+type@sequential+block@abcde' }; + + describe('formatMinAssignmentGrade', () => { + const modifiedGrade = '1'; + const selector = selectors.formatMinAssignmentGrade; + it('passes grade through when not min (0) and assignment is supplied', () => { + expect(selector(modifiedGrade, selectedAssignment)).toEqual(modifiedGrade); + }); + it('returns null for min grade', () => { + expect(selector(minGrade, selectedAssignment)).toEqual(null); + }); + it('returns null when assignment is not supplied', () => { + expect(selector(modifiedGrade, {})).toEqual(null); + }); + }); + + describe('formatMaxAssignmentGrade', () => { + const modifiedGrade = '99'; + const selector = selectors.formatMaxAssignmentGrade; + it('passes grade through when not max (100) and assignment is supplied', () => { + expect(selector(modifiedGrade, selectedAssignment)).toEqual(modifiedGrade); + }); + it('returns null for max grade', () => { + expect(selector(maxGrade, selectedAssignment)).toEqual(null); + }); + it('returns null when assignment is not supplied', () => { + expect(selector(modifiedGrade, {})).toEqual(null); + }); + }); + + describe('formatMinCourseGrade', () => { + const modifiedGrade = '37'; + const selector = selectors.formatMinCourseGrade; + it('passes grades through when not min (0) and assignment is supplied', () => { + expect(selector(modifiedGrade, selectedAssignment)).toEqual(modifiedGrade); + }); + it('returns null for min grade', () => { + expect(selector(minGrade, selectedAssignment)).toEqual(null); + }); + }); + + describe('formatMaxCourseGrade', () => { + const modifiedGrade = '42'; + const selector = selectors.formatMaxCourseGrade; + it('passes grades through when not max and assignment is supplied', () => { + expect(selector(modifiedGrade, selectedAssignment)).toEqual(modifiedGrade); + }); + it('returns null for max grade', () => { + expect(selector(maxGrade, selectedAssignment)).toEqual(null); + }); }); }); + + describe('headingMapper', () => { + const expectedHeaders = (subsectionLabels) => ([ + USERNAME_HEADING, + EMAIL_HEADING, + ...subsectionLabels, + TOTAL_COURSE_GRADE_HEADING, + ]); + + const rows = genericResultsRows; + const selector = selectors.headingMapper; + it('creates headers for all assignments when no filtering is applied', () => { + expect(selector('All')(genericResultsRows)).toEqual( + expectedHeaders([rows[0].label, rows[1].label, rows[2].label]), + ); + }); + it('creates headers for only matching assignment types when type filter is applied', () => { + expect( + selector('Homework')(genericResultsRows), + ).toEqual( + expectedHeaders([rows[0].label, rows[1].label]), + ); + }); + it('creates headers for only matching assignment when label filter is applied', () => { + expect(selector('Homework', rows[1].label)(rows)).toEqual( + expectedHeaders([rows[1].label]), + ); + }); + it('returns an empty array when no entries are passed', () => { + expect(selector('all')(undefined)).toEqual([]); + }); + }); + + describe('transformHistoryEntry', () => { + let getRowsProcessed; + let output; + const rowsProcessed = ['some', 'fake', 'rows']; + const rawEntry = { + modified: 'Jan 10 2021', + original_filename: 'fileName', + data: { some: 'data' }, + also: 'some', + other: 'fields', + }; + beforeEach(() => { + getRowsProcessed = selectors.getRowsProcessed; + selectors.getRowsProcessed = jest.fn(data => ({ data, rowsProcessed })); + output = selectors.transformHistoryEntry(rawEntry); + }); + afterEach(() => { + selectors.getRowsProcessed = getRowsProcessed; + }); + it('transforms modified into timeUploaded', () => { + expect(output.timeUploaded).toEqual(formatDateForDisplay(new Date(rawEntry.modified))); + }); + it('forwards filename', () => { + expect(output.originalFilename).toEqual(rawEntry.original_filename); + }); + it('summarizes processed rows', () => { + expect(output.summaryOfRowsProcessed).toEqual(selectors.getRowsProcessed(rawEntry.data)); + }); + }); + + // Selectors + describe('allGrades', () => { + it('returns the grades results from redux state', () => { + const results = ['some', 'fake', 'results']; + expect(selectors.allGrades({ grades: { results } })).toEqual(results); + }); + }); + + describe('bulkImportError', () => { + it('returns an empty string when bulkManagement not run', () => { + expect( + selectors.bulkImportError({ grades: { bulkManagement: null } }), + ).toEqual(''); + }); + + it('returns an empty string when bulkManagement runs without error', () => { + expect( + selectors.bulkImportError({ grades: { bulkManagement: { uploadSuccess: true } } }), + ).toEqual(''); + }); + + it('returns error string when bulkManagement encounters an error', () => { + const errorMessages = ['error1', 'also error2']; + expect( + selectors.bulkImportError({ grades: { bulkManagement: { errorMessages } } }), + ).toEqual( + `Errors while processing: ${errorMessages[0]}, ${errorMessages[1]}`, + ); + }); + }); + + describe('bulkManagementHistory', () => { + const selector = selectors.bulkManagementHistory; + it('returns history entries from grades.bulkManagement in redux store', () => { + const history = ['a', 'few', 'history', 'entries']; + expect( + selector({ grades: { bulkManagement: { history } } }), + ).toEqual(history); + }); + it('returns an empty list if not set', () => { + expect( + selector({ grades: { bulkManagement: {} } }), + ).toEqual([]); + }); + }); + + describe('bulkManagementHistoryEntries', () => { + let bulkManagementHistory; + let transformHistoryEntry; + const listFn = (state) => state.entries; + const mapFn = (entry) => ([entry]); + const entries = ['some', 'entries', 'for', 'testing']; + beforeEach(() => { + bulkManagementHistory = selectors.bulkManagementHistory; + transformHistoryEntry = selectors.transformHistoryEntry; + selectors.bulkManagementHistory = jest.fn(listFn); + selectors.transformHistoryEntry = jest.fn(mapFn); + }); + afterEach(() => { + selectors.bulkManagementHistory = bulkManagementHistory; + selectors.transformHistoryEntry = transformHistoryEntry; + }); + it('returns history entries mapped to transformer', () => { + expect( + selectors.bulkManagementHistoryEntries({ entries }), + ).toEqual(entries.map(mapFn)); + }); + }); + + describe('getExampleSectionBreakdown', () => { + const selector = selectors.getExampleSectionBreakdown; + it('returns an empty array when results are unavailable', () => { + expect(selector({ grades: { results: [] } })).toEqual([]); + }); + it('returns an empty array when breakdowns are unavailable', () => { + expect(selector({ grades: { results: [{ foo: 'bar' }] } })).toEqual([]); + }); + it('gets section breakdown when available', () => { + const sectionBreakdown = { fake: 'section', breakdown: 'data' }; + expect( + selector({ grades: { results: [{ section_breakdown: sectionBreakdown }] } }), + ).toEqual(sectionBreakdown); + }); + }); + + describe('gradeOverrides', () => { + it('returns grades.gradeOverrideHistoryResults from redux state', () => { + const testVal = 'Temp Test VALUE'; + expect( + selectors.gradeOverrides({ grades: { gradeOverrideHistoryResults: testVal } }), + ).toEqual(testVal); + }); + }); + + describe('uploadSuccess', () => { + const selector = selectors.uploadSuccess; + it('shows upload success when bulkManagement data returned/completed successfully', () => { + expect(selector({ grades: { bulkManagement: { uploadSuccess: true } } })).toEqual(true); + }); + it('returns false when bulk management data not returned', () => { + expect(selector({ grades: {} })).toEqual(false); + }); + }); + + describe('simpleSelectors', () => { + const testVal = 'some TEST value'; + const testSimpleSelector = (key) => { + test(key, () => { + expect( + exportedSelectors[key]({ grades: { [key]: testVal } }), + ).toEqual(testVal); + }); + }; + testSimpleSelector('courseId'); + testSimpleSelector('filteredUsersCount'); + testSimpleSelector('totalUsersCount'); + testSimpleSelector('gradeFormat'); + testSimpleSelector('showSpinner'); + testSimpleSelector('gradeOverrideCurrentEarnedGradedOverride'); + testSimpleSelector('gradeOverrideHistoryError'); + testSimpleSelector('gradeOriginalEarnedGraded'); + testSimpleSelector('gradeOriginalPossibleGraded'); + testSimpleSelector('showSuccess'); + }); }); diff --git a/src/data/selectors/index.js b/src/data/selectors/index.js index 06efd54..93d8a01 100644 --- a/src/data/selectors/index.js +++ b/src/data/selectors/index.js @@ -1,5 +1,8 @@ +/* eslint-disable import/no-named-as-default-member, import/no-self-import */ +import { StrictDict } from 'utils'; import LmsApiService from 'data/services/LmsApiService'; +import * as module from '.'; import assignmentTypes from './assignmentTypes'; import cohorts from './cohorts'; import filters from './filters'; @@ -8,7 +11,13 @@ import roles from './roles'; import special from './special'; import tracks from './tracks'; -const lmsApiServiceArgs = (state) => ({ +/** + * lmsApiServiceArgs(state) + * Returns common lms api service request args. + * @param {object} state - redux state + * @return {object} lms api query params object + */ +export const lmsApiServiceArgs = (state) => ({ cohort: cohorts.getCohortNameById(state, filters.cohort(state)), assignment: filters.selectedAssignmentId(state), assignmentType: filters.assignmentType(state), @@ -24,36 +33,71 @@ const lmsApiServiceArgs = (state) => ({ courseGradeMax: grades.formatMaxCourseGrade(filters.courseGradeMax(state)), }); -const gradeExportUrl = (state, { courseId }) => ( +/** + * gradeExportUrl(state, options) + * Returns the output of getGradeExportCsvUrl, applying the current includeCourseRoleMembers + * filter. + * @param {object} state - redux state + * @param {object} options - options object of the form ({ courseId }) + * @return {string} - generated grade export url + */ +export const gradeExportUrl = (state, { courseId }) => ( LmsApiService.getGradeExportCsvUrl(courseId, { - ...lmsApiServiceArgs(state), + ...module.lmsApiServiceArgs(state), excludeCourseRoles: filters.includeCourseRoleMembers(state) ? '' : 'all', }) ); -const interventionExportUrl = (state, { courseId }) => ( +/** + * interventionExportUrl(state, options) + * Returns the output of getInterventionExportUrl. + * @param {object} state - redux state + * @param {object} options - options object of the form ({ courseId }) + * @return {string} - generated intervention export url + */ +export const interventionExportUrl = (state, { courseId }) => ( LmsApiService.getInterventionExportCsvUrl( courseId, - lmsApiServiceArgs(state), + module.lmsApiServiceArgs(state), ) ); -const showBulkManagement = (state, { courseId }) => ( - special.hasSpecialBulkManagementAccess(courseId) - || (tracks.stateHasMastersTrack(state) && state.config.bulkManagementAvailable) -); - -const shouldShowSpinner = (state) => { - const canView = roles.canUserViewGradebook(state); - return canView && grades.showSpinner(state); -}; - -const getHeadings = (state) => grades.headingMapper( +/** + * getHeadings(state) + * Returns the table headings given the current assignmentType and assignmentLabel filters. + * @param {object} state - redux state + * @return {string[]} - array of table headings + */ +export const getHeadings = (state) => grades.headingMapper( filters.assignmentType(state) || 'All', filters.selectedAssignmentLabel(state) || 'All', )(grades.getExampleSectionBreakdown(state)); -export default { +/** + * showBulkManagement(state, options) + * Returns true iff the user has special access or bulk management is configured to be available + * and the course has a masters track. + * @param {object} state - redux state + * @param {object} options - options object of the form ({ courseId }) + * @return {bool} - should show bulk management controls? + */ +export const showBulkManagement = (state, { courseId }) => ( + special.hasSpecialBulkManagementAccess(courseId) + || (tracks.stateHasMastersTrack(state) && state.config.bulkManagementAvailable) +); + +/** + * shouldShowSpinner(state) + * Returns true iff the user can view the gradebook and grades.showSpinner is true. + * @param {object} state - redux state + * @return {bool} - should show spinner? + */ +export const shouldShowSpinner = (state) => ( + roles.canUserViewGradebook(state) + && grades.showSpinner(state) +); + +export default StrictDict({ root: { getHeadings, gradeExportUrl, @@ -68,4 +112,4 @@ export default { roles, special, tracks, -}; +}); diff --git a/src/data/selectors/index.test.js b/src/data/selectors/index.test.js index 1d635e8..93fdf8d 100644 --- a/src/data/selectors/index.test.js +++ b/src/data/selectors/index.test.js @@ -1,156 +1,217 @@ +/* eslint-disable import/no-named-as-default-member */ import selectors from '.'; -import LmsApiService from '../services/LmsApiService'; +import * as moduleSelectors from '.'; -describe('root', () => { +jest.mock('../services/LmsApiService', () => ({ + __esModule: true, + default: { + getGradeExportCsvUrl: jest.fn( + (...args) => ({ getGradeExportCsvUrl: { args } }), + ), + getInterventionExportCsvUrl: jest.fn( + (...args) => ({ getInterventionExportCsvUrl: { args } }), + ), + }, +})); + +const mockFn = (key) => jest.fn((state) => ({ [key]: state })); +const mockMetaFn = (key) => jest.fn((...args) => ({ [key]: { args } })); +const testState = { a: 'test', state: 'of', random: 'data' }; + +describe('root selectors', () => { const testCourseId = 'OxfordX+Time+Travel'; - const mockAssignmentId = 'block-v1:edX+Term+type@sequential+block@1'; const mockAssignmentType = 'Homework'; const mockAssignmentLabel = 'HW 42'; - const mockCohort = 'test cohort'; - const baseApiArgs = { - assignment: mockAssignmentId, - assignmentGradeMax: '99', - assignmentGradeMin: '1', - assignmentType: mockAssignmentType, - cohort: mockCohort, - courseGradeMax: '98', - courseGradeMin: '2', - }; - - beforeEach(() => { - selectors.cohorts.getCohortNameById = jest.fn(() => mockCohort); - selectors.filters.assignmentType = jest.fn(() => mockAssignmentType); - selectors.filters.includeCourseRoleMembers = jest.fn(); - selectors.filters.selectedAssignmentId = jest.fn(() => mockAssignmentId); - selectors.grades.formatMaxAssignmentGrade = jest.fn(() => '99'); - selectors.grades.formatMinAssignmentGrade = jest.fn(() => '1'); - selectors.grades.formatMaxCourseGrade = jest.fn(() => '98'); - selectors.grades.formatMinCourseGrade = jest.fn(() => '2'); - - // Internal functions, intentionally left blank - selectors.filters.assignmentGradeMax = jest.fn(); - selectors.filters.assignmentGradeMin = jest.fn(); - selectors.filters.cohort = jest.fn(); - selectors.filters.courseGradeMin = jest.fn(); - selectors.filters.courseGradeMax = jest.fn(); - }); - - describe('getHeadings', () => { - const mockHeadingMapper = jest.fn(); - - beforeEach(() => { - // Note: the mock setup for this is gross which I'd argue speaks to the need for refactoring. - mockHeadingMapper.mockReturnValue(() => (() => [])); - - selectors.grades.headingMapper = mockHeadingMapper; - selectors.filters.selectedAssignmentLabel = jest.fn(); - selectors.grades.getExampleSectionBreakdown = jest.fn(); - }); - - it('uses all assignment types for creating headings when no type/assignment filters are supplied', () => { - selectors.filters.assignmentType.mockReturnValue(undefined); - selectors.filters.selectedAssignmentLabel.mockReturnValue(undefined); - selectors.root.getHeadings({}); - expect(mockHeadingMapper).toHaveBeenCalledWith('All', 'All'); - }); - - it('filters headings by assignment type when type filter is applied', () => { - selectors.filters.assignmentType.mockReturnValue(mockAssignmentType); - selectors.filters.selectedAssignmentLabel.mockReturnValue(undefined); - selectors.root.getHeadings({}); - expect(mockHeadingMapper).toHaveBeenCalledWith(mockAssignmentType, 'All'); - }); - - it('filters headings by assignment when a type and assignment filter are applied', () => { - selectors.filters.assignmentType.mockReturnValue(mockAssignmentType); - selectors.filters.selectedAssignmentLabel.mockReturnValue(mockAssignmentLabel); - selectors.root.getHeadings({}); - expect(mockHeadingMapper).toHaveBeenCalledWith(mockAssignmentType, mockAssignmentLabel); + describe('lmsApiServiceArgs', () => { + it('gathers formatted data from state for various lms api service calls', () => { + selectors.cohorts.getCohortNameById = mockFn('getCohortNameById'); + selectors.filters.selectedAssignmentId = mockFn('selectedAssignmentId'); + selectors.filters.assignmentType = mockFn('assignmentType'); + selectors.filters.cohort = mockFn('cohort'); + selectors.filters.assignmentGradeMax = mockFn('assignmentGradeMax'); + selectors.filters.assignmentGradeMin = mockFn('assignmentGradeMin'); + selectors.filters.courseGradeMax = mockFn('courseGradeMax'); + selectors.filters.courseGradeMin = mockFn('courseGradeMin'); + selectors.grades.formatMaxAssignmentGrade = mockMetaFn('formatMaxAssignmentGrade'); + selectors.grades.formatMinAssignmentGrade = mockMetaFn('formatMinAssignmentGrade'); + selectors.grades.formatMinCourseGrade = mockFn('formatMinCourseGrade'); + selectors.grades.formatMaxCourseGrade = mockFn('formatMaxCourseGrade'); + const assignmentId = { selectedAssignmentId: testState }; + expect(moduleSelectors.lmsApiServiceArgs(testState)).toEqual({ + cohort: { getCohortNameById: testState }, + assignment: assignmentId, + assignmentType: { assignmentType: testState }, + assignmentGradeMin: { + formatMinAssignmentGrade: { + args: [{ assignmentGradeMin: testState }, { assignmentId }], + }, + }, + assignmentGradeMax: { + formatMaxAssignmentGrade: { + args: [{ assignmentGradeMax: testState }, { assignmentId }], + }, + }, + courseGradeMin: { + formatMinCourseGrade: { courseGradeMin: testState }, + }, + courseGradeMax: { + formatMaxCourseGrade: { courseGradeMax: testState }, + }, + }); }); }); describe('gradeExportUrl', () => { - it('calls the API service with the right args, excluding all course roles', () => { - const testState = {}; - const mockGetExportUrl = jest.fn(); - const expectedApiArgs = { ...baseApiArgs, excludeCourseRoles: 'all' }; - - LmsApiService.getGradeExportCsvUrl = mockGetExportUrl; - - selectors.root.gradeExportUrl(testState, { courseId: testCourseId }); - expect(mockGetExportUrl).toHaveBeenCalledWith(testCourseId, expectedApiArgs); + const selector = moduleSelectors.gradeExportUrl; + let lmsApiServiceArgs; + beforeEach(() => { + selectors.filters.includeCourseRoleMembers = jest.fn(); + lmsApiServiceArgs = moduleSelectors.lmsApiServiceArgs; + moduleSelectors.lmsApiServiceArgs = jest.fn(state => ({ lmsArgs: state })); }); - it('calls the API service with the right args, including course roles when the option is selected', () => { - const testState = {}; - const mockGetExportUrl = jest.fn(); - const expectedApiArgs = { ...baseApiArgs, excludeCourseRoles: '' }; - selectors.filters.includeCourseRoleMembers.mockReturnValue(true); - - LmsApiService.getGradeExportCsvUrl = mockGetExportUrl; - - selectors.root.gradeExportUrl(testState, { courseId: testCourseId }); - expect(mockGetExportUrl).toHaveBeenCalledWith(testCourseId, expectedApiArgs); + afterEach(() => { + moduleSelectors.lmsApiServiceArgs = lmsApiServiceArgs; + }); + describe('without includeCourseRoleMembers filter', () => { + it('calls the API service with the right args, excluding all course roles', () => { + selectors.filters.includeCourseRoleMembers.mockReturnValue(undefined); + expect(selector(testState, { courseId: testCourseId })).toEqual({ + getGradeExportCsvUrl: { + args: [testCourseId, { lmsArgs: testState, excludeCourseRoles: 'all' }], + }, + }); + }); + }); + describe('with includeCourseRoleMembers filter', () => { + it('calls the API service with the right args, including course roles', () => { + selectors.filters.includeCourseRoleMembers.mockReturnValue(true); + expect(selector(testState, { courseId: testCourseId })).toEqual({ + getGradeExportCsvUrl: { + args: [testCourseId, { lmsArgs: testState, excludeCourseRoles: '' }], + }, + }); + }); }); }); describe('interventionExportUrl', () => { it('calls the API service with the right args', () => { - const testState = {}; - const mockGetExportUrl = jest.fn(); + const { lmsApiServiceArgs } = moduleSelectors; + selectors.filters.includeCourseRoleMembers = jest.fn(); + moduleSelectors.lmsApiServiceArgs = jest.fn(state => ({ lmsArgs: state })); + expect( + moduleSelectors.interventionExportUrl(testState, { courseId: testCourseId }), + ).toEqual({ + getInterventionExportCsvUrl: { + args: [testCourseId, { lmsArgs: testState }], + }, + }); + moduleSelectors.lmsApiServiceArgs = lmsApiServiceArgs; + }); + }); - LmsApiService.getInterventionExportCsvUrl = mockGetExportUrl; - - selectors.root.interventionExportUrl(testState, { courseId: testCourseId }); - expect(mockGetExportUrl).toHaveBeenCalledWith(testCourseId, baseApiArgs); + describe('getHeadings', () => { + const selector = moduleSelectors.getHeadings; + beforeEach(() => { + selectors.grades.headingMapper = jest.fn( + (type, label) => (breakdown) => ({ headingMapper: { type, label, breakdown } }), + ); + selectors.filters.assignmentType = jest.fn(); + selectors.filters.selectedAssignmentLabel = jest.fn(); + selectors.grades.getExampleSectionBreakdown = mockFn('getExampleSectionBreakdown'); + }); + describe('no assignmentType or label selected', () => { + it('maps selected filters into getExampleSectionBreakdown', () => { + selectors.filters.assignmentType.mockReturnValue(undefined); + selectors.filters.selectedAssignmentLabel.mockReturnValue(undefined); + expect(selector(testState)).toEqual({ + headingMapper: { + type: 'All', + label: 'All', + breakdown: { getExampleSectionBreakdown: testState }, + }, + }); + }); + }); + describe('assignmentType and label selected', () => { + it('maps selected filters into getExampleSectionBreakdown', () => { + selectors.filters.assignmentType.mockReturnValue(mockAssignmentType); + selectors.filters.selectedAssignmentLabel.mockReturnValue(mockAssignmentLabel); + expect(selector(testState)).toEqual({ + headingMapper: { + type: mockAssignmentType, + label: mockAssignmentLabel, + breakdown: { getExampleSectionBreakdown: testState }, + }, + }); + }); }); }); describe('showBulkManagement', () => { - let state = {}; - const mockCourseInfo = { courseId: 'foo' }; - - beforeEach(() => { - const templateState = { config: { bulkManagementAvailable: true } }; - state = { ...templateState }; - - selectors.special.hasSpecialBulkManagementAccess = jest.fn(() => (false)); - selectors.tracks.stateHasMastersTrack = jest.fn(() => (false)); + const mockAccess = (val) => { + selectors.special.hasSpecialBulkManagementAccess = jest.fn(() => val); + }; + const mockHasMastersTrack = (val) => { + selectors.tracks.stateHasMastersTrack = jest.fn(() => val); + }; + const selector = moduleSelectors.showBulkManagement; + const mkState = (bulkManagementAvailable) => ({ config: { bulkManagementAvailable } }); + describe('user has special bulk management access', () => { + it('returns true', () => { + mockAccess(true); + mockHasMastersTrack(false); + expect(selector(mkState(true), { courseId: testCourseId })).toEqual(true); + }); }); - - it('does not show bulk management when the course does not have a masters track', () => { - expect(selectors.root.showBulkManagement(state, mockCourseInfo)).toEqual(false); - }); - it('shows bulk management when the course has a masters track', () => { - selectors.tracks.stateHasMastersTrack = jest.fn(() => (true)); - expect(selectors.root.showBulkManagement(state, mockCourseInfo)).toEqual(true); - }); - it('shows bulk management when a course is configured for special access, regardless of other settings', () => { - selectors.special.hasSpecialBulkManagementAccess = jest.fn(() => (true)); - expect(selectors.root.showBulkManagement(state, mockCourseInfo)).toEqual(true); - }); - it('does not show bulk management when bulk management is not available', () => { - selectors.tracks.stateHasMastersTrack = jest.fn(() => (true)); - state.config.bulkManagementAvailable = false; - expect(selectors.root.showBulkManagement(state, mockCourseInfo)).toEqual(false); + describe('user does not have special access', () => { + beforeEach(() => { + mockAccess(false); + }); + describe('course has a masters track, but bulkManagement not available', () => { + it('returns false', () => { + mockHasMastersTrack(true); + expect(selector(mkState(false), { courseId: testCourseId })).toEqual(false); + }); + }); + describe('course does not have a masters track, but bulkManagement available', () => { + it('returns false', () => { + mockHasMastersTrack(false); + expect(selector(mkState(true), { courseId: testCourseId })).toEqual(false); + }); + }); + describe('course has a masters track, and bulkManagement is available', () => { + it('returns false', () => { + mockHasMastersTrack(true); + expect(selector(mkState(true), { courseId: testCourseId })).toEqual(true); + }); + }); }); }); describe('shouldShowSpinner', () => { - it('does not show the spinner if the user cannot view Gradebook', () => { - selectors.roles.canUserViewGradebook = jest.fn(() => (false)); - expect(selectors.root.shouldShowSpinner()).toEqual(false); + const selector = moduleSelectors.shouldShowSpinner; + const testSelector = (canView, showSpinner, expected) => { + selectors.roles.canUserViewGradebook = jest.fn(() => canView); + selectors.grades.showSpinner = jest.fn(() => showSpinner); + expect(selector(testState)).toEqual(expected); + }; + describe('user can view gradebook, but showSpinner is false', () => { + it('returns false', () => { + testSelector(true, false, false); + }); }); - it('shows the spinner when a grades task is processing', () => { - selectors.roles.canUserViewGradebook = jest.fn(() => (true)); - selectors.grades.showSpinner = jest.fn(() => (true)); - expect(selectors.root.shouldShowSpinner()).toEqual(true); + describe('user cannot view gradebook, but showSpinner is true', () => { + it('returns false', () => { + testSelector(false, true, false); + }); }); - it('stops showing the spinner when a grades task is not processing', () => { - selectors.roles.canUserViewGradebook = jest.fn(() => (true)); - selectors.grades.showSpinner = jest.fn(() => (false)); - expect(selectors.root.shouldShowSpinner()).toEqual(false); + describe('user can view gradebook, and showSpinner is true', () => { + it('returns true', () => { + testSelector(true, true, true); + }); }); }); }); diff --git a/src/data/selectors/roles.js b/src/data/selectors/roles.js index d46a11f..c8cada4 100644 --- a/src/data/selectors/roles.js +++ b/src/data/selectors/roles.js @@ -1,5 +1,7 @@ -const selectors = { - canUserViewGradebook: ({ roles }) => roles.canUserViewGradebook, -}; +import { StrictDict } from 'utils'; -export default selectors; +const canUserViewGradebook = ({ roles }) => !!roles.canUserViewGradebook; + +export default StrictDict({ + canUserViewGradebook, +}); diff --git a/src/data/selectors/roles.test.js b/src/data/selectors/roles.test.js index 83ad779..e9fe62a 100644 --- a/src/data/selectors/roles.test.js +++ b/src/data/selectors/roles.test.js @@ -1,23 +1,25 @@ import selectors from './roles'; -describe('canUserViewGradebook', () => { - it('returns true if the user has the canUserViewGradebook role', () => { - const canUserViewGradebook = selectors.canUserViewGradebook({ - roles: { - canUserViewGradebook: true, - canUserDoTheMonsterMash: false, - }, +describe('roles selectors', () => { + describe('canUserViewGradebook', () => { + it('returns true if the user has the canUserViewGradebook role', () => { + const canUserViewGradebook = selectors.canUserViewGradebook({ + roles: { + canUserViewGradebook: true, + canUserDoTheMonsterMash: false, + }, + }); + expect(canUserViewGradebook).toBeTruthy(); }); - expect(canUserViewGradebook).toBeTruthy(); - }); - it('returns false if the user does not have the canUserViewGradebook role', () => { - const canUserViewGradebook = selectors.canUserViewGradebook({ - roles: { - canUserViewGradebook: false, - canUserDoTheMonsterMash: true, - }, + it('returns false if the user does not have the canUserViewGradebook role', () => { + const canUserViewGradebook = selectors.canUserViewGradebook({ + roles: { + canUserViewGradebook: false, + canUserDoTheMonsterMash: true, + }, + }); + expect(canUserViewGradebook).toBeFalsy(); }); - expect(canUserViewGradebook).toBeFalsy(); }); }); diff --git a/src/data/selectors/special.js b/src/data/selectors/special.js index 1055d80..2f0258d 100644 --- a/src/data/selectors/special.js +++ b/src/data/selectors/special.js @@ -1,13 +1,22 @@ +import { StrictDict } from 'utils'; + // Certain course runs may be expressly allowed to view the // bulk management tools, bypassing the other checks. // Note that this does not affect whether or not the backend // LMS API will permit usage of the tool. -const selectors = { - hasSpecialBulkManagementAccess: (courseId) => { - const specialIdList = process.env.BULK_MANAGEMENT_SPECIAL_ACCESS_COURSE_IDS || ''; - return specialIdList.split(',').includes(courseId); - }, +/** +* hasSpecialBulkManagementAccess(courseId) +* Returns true iff the bulk management special access course ids env variable includes +* the linked course id. +* @param {string} courseId - linked course id +* @param {bool} - course has special bulk management access? +*/ +const hasSpecialBulkManagementAccess = (courseId) => { + const specialIdList = process.env.BULK_MANAGEMENT_SPECIAL_ACCESS_COURSE_IDS || ''; + return specialIdList.split(',').includes(courseId); }; -export default selectors; +export default StrictDict({ + hasSpecialBulkManagementAccess, +}); diff --git a/src/data/selectors/tracks.js b/src/data/selectors/tracks.js index 5b78000..ccd15cb 100644 --- a/src/data/selectors/tracks.js +++ b/src/data/selectors/tracks.js @@ -1,18 +1,36 @@ -const compose = (...fns) => { - const [firstFunc, ...rest] = fns.reverse(); - return (...args) => rest.reduce((accum, fn) => fn(accum), firstFunc(...args)); -}; +/* eslint-disable import/no-self-import */ +import { StrictDict } from 'utils'; +import * as module from './tracks'; -const allTracks = state => state.tracks.results || []; -const trackIsMasters = track => track.slug === 'masters'; -const hasMastersTrack = tracks => tracks.some(trackIsMasters); -const stateHasMastersTrack = compose(hasMastersTrack, allTracks); +export const mastersKey = 'masters'; -const selectors = { +/** + * hasMastersTrack(tracks) + * returns true if at least one track in the list is masters track + * @param {object[]} tracks - list of track objects + * @return {bool} - are any of the tracks a masters track? + */ +export const hasMastersTrack = tracks => tracks.some(({ slug }) => slug === mastersKey); + +// Selectors +/** + * allTracks(state) + * returns all tracks resuls from top-level redux state + * @param {object} state - redux state + * @return {object[]} - list of track result entries + */ +export const allTracks = state => state.tracks.results || []; + +/** + * stateHasMastersTrack(state) + * returns true if the state has a masters track entry. + * @param {object} state - redux state + * @return {bool} - does the state have a masters track entry? + */ +export const stateHasMastersTrack = (state) => module.hasMastersTrack(module.allTracks(state)); + +export default StrictDict({ allTracks, hasMastersTrack, stateHasMastersTrack, - trackIsMasters, -}; - -export default selectors; +}); diff --git a/src/data/selectors/tracks.test.js b/src/data/selectors/tracks.test.js index 73d8c69..ae7adc4 100644 --- a/src/data/selectors/tracks.test.js +++ b/src/data/selectors/tracks.test.js @@ -1,88 +1,49 @@ -import selectors from './tracks'; +import * as selectors from './tracks'; -const nonMastersTrack = { - slug: 'honor', - name: 'Honor Code Certificate', - min_price: 0, - suggested_prices: '', - currency: 'usd', - expiration_datetime: null, - description: null, - sku: null, - bulk_sku: null, -}; +const tracksWithMasters = [{ slug: selectors.mastersKey }, { slug: 'other track' }]; +const tracksWithoutMasters = [{ slug: 'fake track' }, { slug: 'other track' }]; -const mastersTrack = { - slug: 'masters', - name: 'Masters track', - min_price: 0, - suggested_prices: 'a lot', - currency: 'usd', - expiration_datetime: null, - description: null, - sku: null, - bulk_sku: null, -}; +describe('tracks selectors', () => { + // Transformers + describe('hasMastersTrack', () => { + const selector = selectors.hasMastersTrack; + it('returns true if a masters track is present', () => { + expect(selector(tracksWithMasters)).toEqual(true); + }); -const exampleTracksWithoutMasters = [nonMastersTrack]; -const exampleTracksWithMasters = [nonMastersTrack, mastersTrack]; - -describe('allTracks', () => { - it('returns an empty array if no tracks found', () => { - const allTracks = selectors.allTracks({ tracks: {} }); - expect(allTracks).toEqual([]); + it('returns false if a masters track is not present', () => { + expect(selector(tracksWithoutMasters)).toEqual(false); + }); }); - it('returns tracks if included in result', () => { - const allTracks = selectors.allTracks({ tracks: { results: exampleTracksWithoutMasters } }); - expect(allTracks).toEqual([ - { - slug: 'honor', - name: 'Honor Code Certificate', - min_price: 0, - suggested_prices: '', - currency: 'usd', - expiration_datetime: null, - description: null, - sku: null, - bulk_sku: null, - }, - ]); - }); -}); - -describe('hasMastersTrack', () => { - it('returns true if a masters track is present', () => { - const hasMastersTrack = selectors.hasMastersTrack(exampleTracksWithMasters); - expect(hasMastersTrack).toBeTruthy(); - }); - - it('returns false if a masters track is not present', () => { - const hasMastersTrack = selectors.hasMastersTrack(exampleTracksWithoutMasters); - expect(hasMastersTrack).toBeFalsy(); - }); -}); - -describe('stateHasMastersTrack', () => { - it('returns true if a masters track is present', () => { - const stateHasMastersTrack = selectors.stateHasMastersTrack({ tracks: { results: exampleTracksWithMasters } }); - expect(stateHasMastersTrack).toBeTruthy(); - }); - - it('returns false if a masters track is not present', () => { - const stateHasMastersTrack = selectors.stateHasMastersTrack({ tracks: { results: exampleTracksWithoutMasters } }); - expect(stateHasMastersTrack).toBeFalsy(); - }); -}); - -describe('trackIsMasters', () => { - it('returns true if track is a masters track', () => { - const trackIsMasters = selectors.trackIsMasters(mastersTrack); - expect(trackIsMasters).toBeTruthy(); - }); - - it('returns true if track is not a masters track', () => { - const trackIsMasters = selectors.trackIsMasters(nonMastersTrack); - expect(trackIsMasters).toBeFalsy(); + // Selectors + describe('allTracks', () => { + const selector = selectors.allTracks; + it('returns an empty array if no tracks found', () => { + expect(selector({ tracks: {} })).toEqual([]); + }); + + it('returns tracks if included in result', () => { + const results = [{ some: 'example' }, { track: 'results' }]; + expect(selector({ tracks: { results } })).toEqual(results); + }); + }); + + describe('stateHasMastersTrack', () => { + it('returns hasMastersTracks called with allTracks as input', () => { + const testState = { some: 'fake', state: 'values' }; + const mocks = { + allTracks: selectors.allTracks, + hasMastersTrack: selectors.hasMastersTrack, + }; + selectors.allTracks = jest.fn(state => ({ allTracks: state })); + selectors.hasMastersTrack = jest.fn((tracks) => ({ hasMastersTrack: tracks })); + + expect(selectors.stateHasMastersTrack(testState)).toEqual({ + hasMastersTrack: { allTracks: testState }, + }); + selectors.allTracks = mocks.allTracks; + selectors.hasMastersTrack = mocks.hasMastersTrack; + }); }); }); diff --git a/src/data/store.js b/src/data/store.js index 6a72c48..a4b6ed5 100755 --- a/src/data/store.js +++ b/src/data/store.js @@ -4,88 +4,87 @@ import { composeWithDevTools } from 'redux-devtools-extension/logOnlyInProductio import { createLogger } from 'redux-logger'; import { createMiddleware } from 'redux-beacon'; import Segment, { trackEvent, trackPageView } from '@redux-beacon/segment'; -import { GOT_ROLES } from './constants/actionTypes/roles'; -import { - GOT_GRADES, GRADE_UPDATE_SUCCESS, GRADE_UPDATE_FAILURE, UPLOAD_OVERRIDE, - UPLOAD_OVERRIDE_ERROR, BULK_GRADE_REPORT_DOWNLOADED, INTERVENTION_REPORT_DOWNLOADED, -} from './constants/actionTypes/grades'; -import { UPDATE_COURSE_GRADE_LIMITS } from './constants/actionTypes/filters'; +import actions from './actions'; import reducers from './reducers'; const loggerMiddleware = createLogger(); const trackingCategory = 'gradebook'; const eventsMap = { - [GOT_ROLES]: trackPageView(action => ({ + [actions.roles.fetching.received.toString()]: trackPageView(({ payload }) => ({ category: trackingCategory, - page: action.courseId, + page: payload.courseId, })), - [GOT_GRADES]: trackEvent(action => ({ + [actions.grades.fetching.received.toString()]: trackEvent(({ payload }) => ({ name: 'edx.gradebook.grades.displayed', properties: { category: trackingCategory, - label: action.courseId, - track: action.track, - cohort: action.cohort, - assignmentType: action.assignmentType, - prev: action.prev, - next: action.next, + label: payload.courseId, + track: payload.track, + cohort: payload.cohort, + assignmentType: payload.assignmentType, + prev: payload.prev, + next: payload.next, }, })), - [GRADE_UPDATE_SUCCESS]: trackEvent(action => ({ + [actions.grades.update.success.toString()]: trackEvent(({ payload }) => ({ name: 'edx.gradebook.grades.grade_override.succeeded', properties: { category: trackingCategory, - label: action.courseId, - updatedGrades: action.payload.responseData, + label: payload.courseId, + updatedGrades: payload.responseData, }, })), - [GRADE_UPDATE_FAILURE]: trackEvent(action => ({ + [actions.grades.update.failure.toString()]: trackEvent(({ payload }) => ({ name: 'edx.gradebook.grades.grade_override.failed', properties: { category: trackingCategory, - label: action.courseId, - error: action.payload.error, + label: payload.courseId, + error: payload.error, }, })), - [UPLOAD_OVERRIDE]: trackEvent(action => ({ + [actions.grades.uploadOverride.success.toString()]: trackEvent(({ payload }) => ({ name: 'edx.gradebook.grades.upload.grades_overrides.succeeded', properties: { category: trackingCategory, - label: action.courseId, + label: payload.courseId, }, })), - [UPLOAD_OVERRIDE_ERROR]: trackEvent(action => ({ + [actions.grades.uploadOverride.failure.toString()]: trackEvent(({ payload }) => ({ name: 'edx.gradebook.grades.upload.grades_overrides.failed', properties: { category: trackingCategory, - label: action.courseId, - error: action.payload.error, + label: payload.courseId, + error: payload.error, }, })), - [UPDATE_COURSE_GRADE_LIMITS]: trackEvent(action => ({ + [actions.filters.update.courseGradeLimits]: trackEvent(({ payload }) => ({ name: 'edx.gradebook.grades.filter_applied', - label: action.courseId, + label: payload.courseId, properties: { category: trackingCategory, - label: action.courseId, - }, - })), - [BULK_GRADE_REPORT_DOWNLOADED]: trackEvent(action => ({ - name: 'edx.gradebook.reports.grade_export.downloaded', - properties: { - category: trackingCategory, - label: action.courseId, - }, - })), - [INTERVENTION_REPORT_DOWNLOADED]: trackEvent(action => ({ - name: 'edx.gradebook.reports.intervention.downloaded', - properties: { - category: trackingCategory, - label: action.courseId, + label: payload.courseId, }, })), + [actions.grades.downloadReport.bulkGrades.toString()]: trackEvent( + ({ payload }) => ({ + name: 'edx.gradebook.reports.grade_export.downloaded', + properties: { + category: trackingCategory, + label: payload.courseId, + }, + }), + ), + [actions.grades.downloadReport.intervention.toString()]: trackEvent( + ({ payload }) => ({ + name: 'edx.gradebook.reports.intervention.downloaded', + properties: { + category: trackingCategory, + label: payload.courseId, + }, + }), + ), }; const segmentMiddleware = createMiddleware(eventsMap, Segment()); diff --git a/src/data/thunkActions/assignmentTypes.js b/src/data/thunkActions/assignmentTypes.js new file mode 100644 index 0000000..1adec89 --- /dev/null +++ b/src/data/thunkActions/assignmentTypes.js @@ -0,0 +1,26 @@ +/* eslint-disable import/prefer-default-export */ +import { StrictDict } from 'utils'; +import actions from '../actions'; + +import LmsApiService from '../services/LmsApiService'; + +const { fetching, gotGradesFrozen } = actions.assignmentTypes; +const { gotBulkManagementConfig } = actions.config; + +export const fetchAssignmentTypes = courseId => ( + (dispatch) => { + dispatch(fetching.started()); + return LmsApiService.fetchAssignmentTypes(courseId) + .then(response => response.data) + .then((data) => { + dispatch(fetching.received(Object.keys(data.assignment_types))); + dispatch(gotGradesFrozen(data.grades_frozen)); + dispatch(gotBulkManagementConfig(data.can_see_bulk_management)); + }) + .catch(() => { + dispatch(fetching.error()); + }); + } +); + +export default StrictDict({ fetchAssignmentTypes }); diff --git a/src/data/thunkActions/assignmentTypes.test.js b/src/data/thunkActions/assignmentTypes.test.js new file mode 100644 index 0000000..2d15113 --- /dev/null +++ b/src/data/thunkActions/assignmentTypes.test.js @@ -0,0 +1,55 @@ +import LmsApiService from '../services/LmsApiService'; + +import actions from '../actions'; +import * as thunkActions from './assignmentTypes'; +import { createTestFetcher } from './testUtils'; + +jest.mock('../services/LmsApiService', () => ({ + fetchAssignmentTypes: jest.fn(), +})); + +const responseData = { + assignment_types: { + some: 'types', + other: 'TYpeS', + }, + grades_frozen: 'bOOl', + can_see_bulk_management: 'BooL', +}; + +describe('assignmentType thunkActions', () => { + describe('fetchAssignmentTypes', () => { + const courseId = 'course-v1:edX+DemoX+Demo_Course'; + const testFetch = createTestFetcher( + LmsApiService.fetchAssignmentTypes, + thunkActions.fetchAssignmentTypes, + [courseId], + ); + describe('actions dispatched on valid response', () => { + const actionNames = [ + 'fetching.started', + 'fetching.received with data.assignment_types', + 'gotGradesFrozen with data.grades_frozen', + 'config.gotBulkManagement with data.can_see_bulk_management', + ]; + test(actionNames.join(', '), () => testFetch( + (resolve) => resolve({ data: responseData }), + [ + actions.assignmentTypes.fetching.started(), + actions.assignmentTypes.fetching.received(Object.keys(responseData.assignment_types)), + actions.assignmentTypes.gotGradesFrozen(responseData.grades_frozen), + actions.config.gotBulkManagementConfig(responseData.can_see_bulk_management), + ], + )); + }); + describe('actions dispatched on api error', () => { + test('fetching.started, fetching.error', () => testFetch( + (resolve, reject) => reject(), + [ + actions.assignmentTypes.fetching.started(), + actions.assignmentTypes.fetching.error(), + ], + )); + }); + }); +}); diff --git a/src/data/thunkActions/cohorts.js b/src/data/thunkActions/cohorts.js new file mode 100644 index 0000000..46ddc16 --- /dev/null +++ b/src/data/thunkActions/cohorts.js @@ -0,0 +1,21 @@ +/* eslint-disable import/prefer-default-export */ +import { StrictDict } from 'utils'; +import cohorts from '../actions/cohorts'; + +import LmsApiService from '../services/LmsApiService'; + +export const fetchCohorts = courseId => ( + (dispatch) => { + dispatch(cohorts.fetching.started()); + return LmsApiService.fetchCohorts(courseId) + .then(response => response.data) + .then((data) => { + dispatch(cohorts.fetching.received(data.cohorts)); + }) + .catch(() => { + dispatch(cohorts.fetching.error()); + }); + } +); + +export default StrictDict({ fetchCohorts }); diff --git a/src/data/thunkActions/cohorts.test.js b/src/data/thunkActions/cohorts.test.js new file mode 100644 index 0000000..d6e98f5 --- /dev/null +++ b/src/data/thunkActions/cohorts.test.js @@ -0,0 +1,45 @@ +import LmsApiService from '../services/LmsApiService'; + +import actions from '../actions'; +import * as thunkActions from './cohorts'; +import { createTestFetcher } from './testUtils'; + +jest.mock('../services/LmsApiService', () => ({ + fetchCohorts: jest.fn(), +})); + +const responseData = { + cohorts: { + some: 'COHorts', + other: 'cohORT$', + }, +}; + +describe('cohorts thunkActions', () => { + describe('fetchCohorts', () => { + const courseId = 'course-v1:edX+DemoX+Demo_Course'; + const testFetch = createTestFetcher( + LmsApiService.fetchCohorts, + thunkActions.fetchCohorts, + [courseId], + ); + describe('actions dispatched on valid response', () => { + test('fetching.started, fetching.received', () => testFetch( + (resolve) => resolve({ data: responseData }), + [ + actions.cohorts.fetching.started(), + actions.cohorts.fetching.received(responseData.cohorts), + ], + )); + }); + describe('actions dispatched on api error', () => { + test('fetching.started, fetching.error', () => testFetch( + (resolve, reject) => reject(), + [ + actions.cohorts.fetching.started(), + actions.cohorts.fetching.error(), + ], + )); + }); + }); +}); diff --git a/src/data/thunkActions/filters.js b/src/data/thunkActions/filters.js new file mode 100644 index 0000000..d3df1bf --- /dev/null +++ b/src/data/thunkActions/filters.js @@ -0,0 +1,18 @@ +/* eslint-disable import/prefer-default-export */ +import { StrictDict } from 'utils'; +import filters from '../actions/filters'; +import selectors from '../selectors'; + +import { fetchGrades } from './grades'; + +export const updateIncludeCourseRoleMembers = (includeCourseRoleMembers) => (dispatch, getState) => { + dispatch(filters.update.includeCourseRoleMembers(includeCourseRoleMembers)); + const state = getState(); + const { cohort, track, assignmentType } = selectors.filters.allFilters(state); + const courseId = selectors.grades.courseId(state); + dispatch(fetchGrades(courseId, cohort, track, assignmentType)); +}; + +export default StrictDict({ + updateIncludeCourseRoleMembers, +}); diff --git a/src/data/thunkActions/filters.test.js b/src/data/thunkActions/filters.test.js new file mode 100644 index 0000000..5b1201b --- /dev/null +++ b/src/data/thunkActions/filters.test.js @@ -0,0 +1,47 @@ +import selectors from '../selectors'; +import actions from '../actions'; +import { fetchGrades } from './grades'; +import { updateIncludeCourseRoleMembers } from './filters'; + +jest.mock('./grades', () => ({ + fetchGrades: jest.fn((...args) => ({ type: 'fetchGrades', args })), +})); + +jest.mock('../selectors', () => ({ + __esModule: true, + default: { + grades: { courseId: jest.fn() }, + filters: { allFilters: jest.fn() }, + }, +})); + +describe('filters thunkActions', () => { + describe('updateIncludeCourseRoleMembers', () => { + const getState = () => ({}); + const testVal = 'Hawaii'; + const filters = { + cohort: 'COHort', + track: 'TRacK', + assignmentType: 'Prague', + }; + const courseId = 'Some Course ID'; + let dispatch; + beforeEach(() => { + dispatch = jest.fn(); + selectors.filters.allFilters.mockReturnValue(filters); + selectors.grades.courseId.mockReturnValue(courseId); + updateIncludeCourseRoleMembers(testVal)(dispatch, getState); + }); + it('dispatches filters.update.includeCoruseRoleMembers with passed value', () => { + expect(dispatch.mock.calls[0][0]).toEqual(actions.filters.update.includeCourseRoleMembers(testVal)); + }); + it('dispatches fetchGrades with courseId, cohort, track, and assignmentType', () => { + expect(dispatch.mock.calls[1][0]).toEqual(fetchGrades( + courseId, + filters.cohort, + filters.track, + filters.assignmentType, + )); + }); + }); +}); diff --git a/src/data/thunkActions/grades.js b/src/data/thunkActions/grades.js new file mode 100644 index 0000000..8d8fcce --- /dev/null +++ b/src/data/thunkActions/grades.js @@ -0,0 +1,226 @@ +/* eslint-disable import/no-self-import */ +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; + +import { StrictDict } from 'utils'; +import grades from '../actions/grades'; +import { sortAlphaAsc } from '../actions/utils'; + +import selectors from '../selectors'; + +import GRADE_OVERRIDE_HISTORY_ERROR_DEFAULT_MSG from '../constants/errors'; + +import LmsApiService from '../services/LmsApiService'; +import * as module from './grades'; + +const { + formatMaxAssignmentGrade, + formatMinAssignmentGrade, + formatMaxCourseGrade, + formatMinCourseGrade, + formatGradeOverrideForDisplay, +} = selectors.grades; + +export const defaultAssignmentFilter = 'All'; + +export const fetchBulkUpgradeHistory = courseId => ( + // todo add loading effect + dispatch => LmsApiService.fetchGradeBulkOperationHistory(courseId).then( + (response) => { dispatch(grades.bulkHistory.received(response)); }, + ).catch(() => dispatch(grades.bulkHistory.error())) +); + +export const fetchGrades = ( + courseId, + cohort, + track, + assignmentType, + options = {}, +) => ( + (dispatch, getState) => { + dispatch(grades.fetching.started()); + const { + assignment, + assignmentGradeMax: assignmentMax, + assignmentGradeMin: assignmentMin, + courseGradeMin, + courseGradeMax, + includeCourseRoleMembers, + } = selectors.filters.allFilters(getState()); + const { id: assignmentId } = assignment || {}; + const assignmentGradeMax = formatMaxAssignmentGrade(assignmentMax, { assignmentId }); + const assignmentGradeMin = formatMinAssignmentGrade(assignmentMin, { assignmentId }); + const courseGradeMaxFormatted = formatMaxCourseGrade(courseGradeMax); + const courseGradeMinFormatted = formatMinCourseGrade(courseGradeMin); + return LmsApiService.fetchGradebookData( + courseId, + options.searchText || null, + cohort, + track, + { + assignment: assignmentId, + assignmentGradeMax, + assignmentGradeMin, + courseGradeMax: courseGradeMaxFormatted, + courseGradeMin: courseGradeMinFormatted, + includeCourseRoleMembers, + }, + ) + .then(response => response.data) + .then((data) => { + dispatch(grades.fetching.received({ + grades: data.results.sort(sortAlphaAsc), + cohort, + track, + assignmentType, + prev: data.previous, + next: data.next, + courseId, + totalUsersCount: data.total_users_count, + filteredUsersCount: data.filtered_users_count, + })); + dispatch(grades.fetching.finished()); + if (options.showSuccess) { + dispatch(grades.banner.open()); + } + }) + .catch(() => { + dispatch(grades.fetching.error()); + }); + } +); + +export const fetchGradeOverrideHistory = (subsectionId, userId) => ( + dispatch => LmsApiService.fetchGradeOverrideHistory(subsectionId, userId) + .then(response => response.data) + .then((data) => { + if (data.success) { + dispatch(grades.overrideHistory.received({ + overrideHistory: formatGradeOverrideForDisplay(data.history), + currentEarnedAllOverride: data.override ? data.override.earned_all_override : null, + currentPossibleAllOverride: data.override ? data.override.possible_all_override : null, + currentEarnedGradedOverride: data.override ? data.override.earned_graded_override : null, + currentPossibleGradedOverride: data.override + ? data.override.possible_graded_override : null, + originalGradeEarnedAll: data.original_grade ? data.original_grade.earned_all : null, + originalGradePossibleAll: data.original_grade ? data.original_grade.possible_all : null, + originalGradeEarnedGraded: data.original_grade + ? data.original_grade.earned_graded : null, + originalGradePossibleGraded: data.original_grade + ? data.original_grade.possible_graded : null, + })); + } else { + dispatch(grades.overrideHistory.error(data.error_message)); + } + }) + .catch(() => { + dispatch(grades.overrideHistory.error(GRADE_OVERRIDE_HISTORY_ERROR_DEFAULT_MSG)); + }) +); + +export const fetchMatchingUserGrades = ( + courseId, + searchText, + cohort, + track, + assignmentType, + showSuccess, + options = {}, +) => { + const newOptions = { ...options, searchText, showSuccess }; + return module.fetchGrades(courseId, cohort, track, assignmentType, newOptions); +}; + +export const fetchPrevNextGrades = (endpoint, courseId, cohort, track, assignmentType) => ( + (dispatch) => { + dispatch(grades.fetching.started()); + return getAuthenticatedHttpClient().get(endpoint) + .then(({ data }) => data) + .then((data) => { + dispatch(grades.fetching.received({ + grades: data.results.sort(sortAlphaAsc), + cohort, + track, + assignmentType, + prev: data.previous, + next: data.next, + courseId, + totalUsersCount: data.total_users_count, + filteredUsersCount: data.filtered_users_count, + })); + dispatch(grades.fetching.finished()); + }) + .catch(() => { + dispatch(grades.fetching.error()); + }); + } +); + +export const submitFileUploadFormData = (courseId, formData) => ( + (dispatch) => { + dispatch(grades.csvUpload.started()); + return LmsApiService.uploadGradeCsv(courseId, formData).then(() => { + dispatch(grades.csvUpload.finished()); + dispatch(grades.uploadOverride.success(courseId)); + }).catch((err) => { + dispatch(grades.uploadOverride.failure(courseId, err)); + if (err.status === 200 && err.data.error_messages.length) { + const { error_messages: errorMessages, saved, total } = err.data; + return dispatch(grades.csvUpload.error({ errorMessages, saved, total })); + } + return dispatch(grades.csvUpload.error({ errorMessages: ['Unknown error.'] })); + }); + } +); + +export const updateGrades = (courseId, updateData, searchText, cohort, track) => ( + (dispatch) => { + dispatch(grades.update.request()); + return LmsApiService.updateGradebookData(courseId, updateData) + .then(response => response.data) + .then((data) => { + dispatch(grades.update.success({ courseId, data })); + dispatch(module.fetchMatchingUserGrades( + courseId, + searchText, + cohort, + track, + defaultAssignmentFilter, + true, + { searchText }, + )); + }) + .catch((error) => { + dispatch(grades.update.failure({ courseId, error })); + }); + } +); + +export const updateGradesIfAssignmentGradeFiltersSet = ( + courseId, + cohort, + track, + assignmentType, +) => (dispatch, getState) => { + const assignmentGradeMin = selectors.filters.assignmentGradeMin(getState()); + const assignmentGradeMax = selectors.filters.assignmentGradeMax(getState()); + const hasAssignmentGradeFiltersSet = assignmentGradeMax || assignmentGradeMin; + if (hasAssignmentGradeFiltersSet) { + dispatch(module.fetchGrades( + courseId, + cohort, + track, + assignmentType, + )); + } +}; + +export default StrictDict({ + fetchBulkUpgradeHistory, + fetchGrades, + fetchGradeOverrideHistory, + fetchMatchingUserGrades, + fetchPrevNextGrades, + submitFileUploadFormData, + updateGrades, + updateGradesIfAssignmentGradeFiltersSet, +}); diff --git a/src/data/thunkActions/grades.test.js b/src/data/thunkActions/grades.test.js new file mode 100644 index 0000000..3be112d --- /dev/null +++ b/src/data/thunkActions/grades.test.js @@ -0,0 +1,774 @@ +import configureMockStore from 'redux-mock-store'; +import thunk from 'redux-thunk'; + +import * as auth from '@edx/frontend-platform/auth'; +import * as thunkActions from './grades'; +import actions from '../actions'; +import GRADE_OVERRIDE_HISTORY_ERROR_DEFAULT_MSG from '../constants/errors'; +import { sortAlphaAsc } from '../actions/utils'; +import LmsApiService from '../services/LmsApiService'; +import selectors from '../selectors'; + +import { createTestFetcher } from './testUtils'; + +const mockStore = configureMockStore([thunk]); + +const courseId = 'course-v1:edX+DemoX+Demo_Course'; +const sections = [ + { + subsection_name: 'Demo Course Overview', + score_earned: 0, + score_possible: 0, + percent: 0, + displayed_value: '0.00', + grade_description: '(0.00/0.00)', + }, + { + subsection_name: 'Example Week 1: Getting Started', + score_earned: 1, + score_possible: 1, + percent: 1, + displayed_value: '1.00', + grade_description: '(0.00/0.00)', + }, +]; + +const gradesResults = [ + { + course_id: courseId, + section_breakdown: [...sections], + email: 'user1@example.com', + username: 'user1', + user_id: 1, + percent: 0.5, + letter_grade: null, + }, + { + course_id: courseId, + section_breakdown: [...sections], + email: 'user22@example.com', + username: 'user22', + user_id: 22, + percent: 0, + letter_grade: null, + }, +]; + +const allFilters = { + assignment: { id: 'assigNment_ID' }, + assignmentGradeMax: 89, + assignmentGradeMin: 12, + courseGradeMax: 92, + courseGradeMin: 2, + includeCourseRoleMembers: true, +}; + +const testVal = 'A Test VALue'; + +jest.mock('../services/LmsApiService', () => ({ + fetchGradebookData: jest.fn(), + fetchGradeBulkOperationHistory: jest.fn(), + fetchGradeOverrideHistory: jest.fn(), + fetchPrevNextGrades: jest.fn(), + updateGradebookData: jest.fn(), + updateGrades: jest.fn(), + uploadGradeCsv: jest.fn(), +})); +jest.mock('@edx/frontend-platform/auth', () => ({ + getAuthenticatedHttpClient: jest.fn(), +})); +jest.mock('../selectors', () => ({ + __esModule: true, + default: { + grades: { + formatGradeOverrideForDisplay: (override) => ({ + formatGradeOverrideForDisplay: { override }, + }), + formatMaxAssignmentGrade: (grade, { assignmentId }) => ({ + formatMaxAssignmentGrade: { grade, assignmentId }, + }), + formatMinAssignmentGrade: (grade, { assignmentId }) => ({ + formatMinAssignmentGrade: { grade, assignmentId }, + }), + formatMinCourseGrade: (grade) => ({ + formatMinCourseGrade: { grade }, + }), + formatMaxCourseGrade: (grade) => ({ + formatMaxCourseGrade: { grade }, + }), + }, + filters: { + allFilters: jest.fn(), + assignmentGradeMin: jest.fn(), + assignmentGradeMax: jest.fn(), + }, + }, +})); + +selectors.filters.allFilters.mockReturnValue(allFilters); + +describe('grades thunkActions', () => { + beforeEach(() => { + LmsApiService.fetchGradebookData.mockClear(); + LmsApiService.fetchGradeBulkOperationHistory.mockClear(); + LmsApiService.fetchGradeOverrideHistory.mockClear(); + LmsApiService.fetchPrevNextGrades.mockClear(); + LmsApiService.updateGrades.mockClear(); + }); + describe('fetchBulkUpgradeHistory', () => { + const testFetch = createTestFetcher( + LmsApiService.fetchGradeBulkOperationHistory, + thunkActions.fetchBulkUpgradeHistory, + [courseId], + () => expect(LmsApiService.fetchGradeBulkOperationHistory).toHaveBeenCalledWith(courseId), + ); + describe('valid response', () => { + it('dispatches bulkHistory.received with response', () => { + testFetch((resolve) => resolve(testVal), [actions.grades.bulkHistory.received(testVal)]); + }); + }); + describe('api failure', () => { + it('dispatches bulkHistory.error', () => { + testFetch((resolve, reject) => reject(), [actions.grades.bulkHistory.error()]); + }); + }); + }); + + describe('fetchGrades', () => { + const responseData = { + previous: 'PREvGrade', + next: 'nextGRADe', + results: gradesResults, + total_users_count: 10, + filtered_users_count: 8, + }; + const expected = { + grades: gradesResults.sort(sortAlphaAsc), + cohort: 1, + track: 'verified', + assignmentType: 'Exam', + prev: responseData.previous, + next: responseData.next, + courseId, + totalUsersCount: responseData.total_users_count, + filteredUsersCount: responseData.filtered_users_count, + }; + const apiArgs = (options) => { + const { + assignment, + assignmentGradeMax, assignmentGradeMin, + courseGradeMax, courseGradeMin, + includeCourseRoleMembers, + } = selectors.filters.allFilters(); + const assignmentId = (assignment || {}).id; + return [ + courseId, + options.searchText || null, + expected.cohort, + expected.track, + { + assignment: assignmentId, + assignmentGradeMax: selectors.grades.formatMaxAssignmentGrade( + assignmentGradeMax, { assignmentId }, + ), + assignmentGradeMin: selectors.grades.formatMinAssignmentGrade( + assignmentGradeMin, { assignmentId }, + ), + courseGradeMax: selectors.grades.formatMaxCourseGrade( + courseGradeMax, + ), + courseGradeMin: selectors.grades.formatMinCourseGrade( + courseGradeMin, + ), + includeCourseRoleMembers, + }, + ]; + }; + const testFetchWithOptions = (options = false) => createTestFetcher( + LmsApiService.fetchGradebookData, + thunkActions.fetchGrades, + [ + courseId, + expected.cohort, + expected.track, + expected.assignmentType, + options, + ], + () => { + expect( + LmsApiService.fetchGradebookData, + ).toHaveBeenCalledWith(...apiArgs(options)); + }, + ); + const testFetch = testFetchWithOptions(); + + describe('after valid response', () => { + const successActions = [ + actions.grades.fetching.started(), + actions.grades.fetching.received({ ...expected }), + actions.grades.fetching.finished(), + ]; + it('dispatches success', () => testFetch( + (resolve) => resolve({ data: responseData }), + [...successActions], + )); + it('passes the correct args to the fetchGradebookData query', () => { + const assignmentId = allFilters.assignment.id; + return testFetch( + (resolve) => resolve({ data: responseData }), + [...successActions], + () => { + const { + formatMaxAssignmentGrade, + formatMinAssignmentGrade, + formatMaxCourseGrade, + formatMinCourseGrade, + } = selectors.grades; + expect(LmsApiService.fetchGradebookData).toHaveBeenCalledWith( + courseId, + null, + expected.cohort, + expected.track, + { + assignment: allFilters.assignment.id, + assignmentGradeMax: formatMaxAssignmentGrade( + allFilters.assignmentGradeMax, + { assignmentId }, + ), + assignmentGradeMin: formatMinAssignmentGrade( + allFilters.assignmentGradeMin, + { assignmentId }, + ), + courseGradeMax: formatMaxCourseGrade(allFilters.courseGradeMax), + courseGradeMin: formatMinCourseGrade(allFilters.courseGradeMin), + includeCourseRoleMembers: allFilters.includeCourseRoleMembers, + }, + ); + }, + ); + }); + describe('no assignment selected', () => { + beforeAll(() => { + selectors.filters.allFilters.mockReturnValue({ + ...allFilters, + assignment: undefined, + }); + }); + afterAll(() => { + selectors.filters.allFilters.mockReturnValue(allFilters); + }); + it('passes the correct args to the fetchGradebookData query', () => testFetch( + (resolve) => resolve({ data: responseData }), + undefined, + () => { + const { + formatMaxAssignmentGrade, + formatMinAssignmentGrade, + formatMaxCourseGrade, + formatMinCourseGrade, + } = selectors.grades; + expect(LmsApiService.fetchGradebookData).toHaveBeenCalledWith( + courseId, + null, + expected.cohort, + expected.track, + { + assignmentGradeMax: formatMaxAssignmentGrade(allFilters.assignmentGradeMax, {}), + assignmentGradeMin: formatMinAssignmentGrade(allFilters.assignmentGradeMin, {}), + courseGradeMax: formatMaxCourseGrade(allFilters.courseGradeMax), + courseGradeMin: formatMinCourseGrade(allFilters.courseGradeMin), + includeCourseRoleMembers: allFilters.includeCourseRoleMembers, + }, + ); + }, + )); + }); + describe('options', () => { + const resolveFn = (resolve) => resolve({ data: responseData }); + describe('showSuccess', () => { + it('dispatches success and opens banner if true', () => ( + testFetchWithOptions({ showSuccess: true })( + resolveFn, + [...successActions, actions.grades.banner.open()], + ) + )); + it('does not open banner if not true', () => testFetchWithOptions({})( + resolveFn, [...successActions], + )); + }); + describe('searchText', () => { + it('passes searchText to api call if included', () => { + const options = { searchText: 'Search Text' }; + return testFetchWithOptions(options)( + resolveFn, + [...successActions], + () => { + expect( + LmsApiService.fetchGradebookData.mock.calls[0][1], + ).toEqual(options.searchText); + }, + ); + }); + it('passes null to api call for searchText if not included', () => { + const options = {}; + return testFetchWithOptions(options)( + resolveFn, + [...successActions], + () => { + expect( + LmsApiService.fetchGradebookData.mock.calls[0][1], + ).toEqual(null); + }, + ); + }); + }); + }); + }); + describe('empty response', () => { + it('dispatches success on empty response', () => testFetch( + (resolve) => resolve({ data: { ...responseData, results: [] } }), + [ + actions.grades.fetching.started(), + actions.grades.fetching.received({ ...expected, grades: [] }), + actions.grades.fetching.finished(), + ], + )); + }); + describe('after invalid response', () => { + it('dispatches error', () => testFetch( + (resolve, reject) => reject(), + [ + actions.grades.fetching.started(), + actions.grades.fetching.error(), + ], + )); + }); + }); + + describe('fetchGradeOverrideHistory', () => { + const subsectionId = 'subsectionId-11111'; + const userId = 'user-id-11111'; + + const originalGrade = { + earned_all: 1.0, + possible_all: 12.0, + earned_graded: 3.0, + possible_graded: 8.0, + }; + + const override = { + earned_all_override: 13.0, + possible_all_override: 13.0, + earned_graded_override: 10.0, + possible_graded_override: 10.0, + }; + const history = { some: 'history' }; + + const testFetch = createTestFetcher( + LmsApiService.fetchGradeOverrideHistory, + thunkActions.fetchGradeOverrideHistory, + [subsectionId, userId], + () => { + expect( + LmsApiService.fetchGradeOverrideHistory, + ).toHaveBeenCalledWith(subsectionId, userId); + }, + ); + + describe('valid data', () => { + describe('if data.success', () => { + describe('override', () => { + it('loads override values and sets original to null', () => { + const resolveFn = (resolve) => { + resolve({ data: { success: true, history, override } }); + }; + return testFetch(resolveFn, [ + actions.grades.overrideHistory.received({ + overrideHistory: selectors.grades.formatGradeOverrideForDisplay(history), + currentEarnedAllOverride: override.earned_all_override, + currentPossibleAllOverride: override.possible_all_override, + currentEarnedGradedOverride: override.earned_graded_override, + currentPossibleGradedOverride: override.possible_graded_override, + originalGradeEarnedAll: null, + originalGradePossibleAll: null, + originalGradeEarnedGraded: null, + originalGradePossibleGraded: null, + }), + ]); + }); + }); + describe('original', () => { + it('loads original values and sets overrides to null', () => { + const resolveFn = (resolve) => { + resolve({ data: { success: true, history, original_grade: originalGrade } }); + }; + return testFetch(resolveFn, [ + actions.grades.overrideHistory.received({ + overrideHistory: selectors.grades.formatGradeOverrideForDisplay(history), + currentEarnedAllOverride: null, + currentPossibleAllOverride: null, + currentEarnedGradedOverride: null, + currentPossibleGradedOverride: null, + originalGradeEarnedAll: originalGrade.earned_all, + originalGradePossibleAll: originalGrade.possible_all, + originalGradeEarnedGraded: originalGrade.earned_graded, + originalGradePossibleGraded: originalGrade.possible_graded, + }), + ]); + }); + }); + describe('no override or original', () => { + it('loads null for current and override fields', () => { + const resolveFn = (resolve) => { + resolve({ data: { success: true, history } }); + }; + return testFetch(resolveFn, [ + actions.grades.overrideHistory.received({ + overrideHistory: selectors.grades.formatGradeOverrideForDisplay(history), + currentEarnedAllOverride: null, + currentPossibleAllOverride: null, + currentEarnedGradedOverride: null, + currentPossibleGradedOverride: null, + originalGradeEarnedAll: null, + originalGradePossibleAll: null, + originalGradeEarnedGraded: null, + originalGradePossibleGraded: null, + }), + ]); + }); + }); + }); + describe('if not data.success', () => { + it('dispatchs error with error_message', () => { + const errorMessage = 'oh Noooooooo!'; + const resolveFn = (resolve) => { + resolve({ data: { success: false, error_message: errorMessage } }); + }; + return testFetch(resolveFn, [ + actions.grades.overrideHistory.error(errorMessage), + ]); + }); + }); + }); + describe('api failure', () => { + it('sends error action with default error message', () => { + const resolveFn = (resolve, reject) => reject(); + return testFetch(resolveFn, [ + actions.grades.overrideHistory.error( + GRADE_OVERRIDE_HISTORY_ERROR_DEFAULT_MSG, + ), + ]); + }); + }); + }); + + describe('fetchGrades inheritors', () => { + let fetchGrades; + const fetchGradesAction = { type: 'fetchGrades' }; + beforeEach(() => { + fetchGrades = thunkActions.fetchGrades; + thunkActions.fetchGrades = jest.fn().mockImplementation((...args) => ({ + ...fetchGradesAction, + args, + })); + }); + afterEach(() => { + thunkActions.fetchGrades = fetchGrades; + }); + describe('fetchMatchingUserGrades', () => { + it('calls with added searchText and showSuccess options', () => { + const store = mockStore({}); + const args = { + searchText: 'Some SearcH', + cohort: 'coHOrt', + track: 'TRAck', + assignmentType: 'aType', + showSuccess: true, + options: { some: 'options' }, + }; + store.dispatch(thunkActions.fetchMatchingUserGrades( + courseId, + args.searchText, + args.cohort, + args.track, + args.assignmentType, + args.showSuccess, + args.options, + )); + expect(store.getActions()).toEqual([ + { + ...fetchGradesAction, + args: [ + courseId, + args.cohort, + args.track, + args.assignmentType, + { searchText: args.searchText, showSuccess: args.showSuccess, ...args.options }, + ], + }, + ]); + }); + }); + describe('updateGrades', () => { + const args = { + updateData: { some: 'data' }, + searchText: 'SEARch TErm', + cohort: 'COhoRT', + track: 'trACk', + }; + const gradebookData = { data: { OTher: 'DATA' } }; + + const testFetch = createTestFetcher( + LmsApiService.updateGradebookData, + thunkActions.updateGrades, + [courseId, args.updateData, args.searchText, args.cohort, args.track], + () => expect( + LmsApiService.updateGradebookData, + ).toHaveBeenCalledWith(courseId, args.updateData), + ); + let fetchMatchingUserGrades; + const fetchMatchingUserGradesAction = { type: 'fetchMatchingUserGrades' }; + beforeEach(() => { + fetchMatchingUserGrades = jest.spyOn( + thunkActions, + 'fetchMatchingUserGrades', + ).mockImplementation((...actionArgs) => ({ + ...fetchMatchingUserGradesAction, + args: actionArgs, + })); + }); + afterEach(() => { + fetchMatchingUserGrades.mockRestore(); + }); + describe('valid response', () => { + it('sends success event, and fetches matching user grades', () => testFetch( + (resolve) => resolve(gradebookData), + [ + actions.grades.update.request(), + actions.grades.update.success({ courseId, data: gradebookData.data }), + { + ...fetchMatchingUserGradesAction, + args: [ + courseId, + args.searchText, + args.cohort, + args.track, + thunkActions.defaultAssignmentFilter, + true, + { searchText: args.searchText }, + ], + }, + ], + )); + }); + describe('error response', () => { + it('sends failure event', () => { + const error = 'Some ERRor'; + return testFetch( + (resolve, reject) => reject(error), + [ + actions.grades.update.request(), + actions.grades.update.failure({ courseId, error }), + ], + ); + }); + }); + }); + + describe('updateGradesIfAssignmentGradeFiltersSet', () => { + const args = { + cohort: 'coHOrt', + track: 'trAck', + assignmentType: 'bananas', + }; + let assignmentGradeMin; + let assignmentGradeMax; + const mockFilters = (minValue, maxValue) => { + assignmentGradeMax = jest.spyOn( + selectors.filters, 'assignmentGradeMax', + ).mockReturnValue(maxValue); + assignmentGradeMin = jest.spyOn( + selectors.filters, 'assignmentGradeMin', + ).mockReturnValue(minValue); + }; + const callUpdate = (expectedActions) => { + const store = mockStore({}); + store.dispatch(thunkActions.updateGradesIfAssignmentGradeFiltersSet( + courseId, + args.cohort, + args.track, + args.assignmentType, + )); + expect(store.getActions()).toEqual(expectedActions); + }; + afterEach(() => { + assignmentGradeMin.mockRestore(); + assignmentGradeMax.mockRestore(); + }); + describe('if neither assignment grade filter is set', () => { + mockFilters(undefined, undefined); + it('does not call', () => callUpdate([])); + }); + describe('if either assignment grade filter is set', () => { + const assertFetchGradesCalled = () => callUpdate([ + { + ...fetchGradesAction, + args: [ + courseId, + args.cohort, + args.track, + args.assignmentType, + ], + }, + ]); + it('calls if min is set', () => { + mockFilters(21, undefined); + return assertFetchGradesCalled(); + }); + it('calls if max is set', () => { + mockFilters(undefined, 92); + return assertFetchGradesCalled(); + }); + }); + }); + }); + + describe('fetchPrevNextGrades', () => { + const args = { + endpoint: 'someEndpoint', + cohort: 'COhoRT', + track: 'TracK', + assignmentType: '23', + }; + const response = { + results: gradesResults, + previous: 'Prev', + next: 'NEXT', + total_users_count: 23, + filtered_users_count: 12, + }; + const { fetching } = actions.grades; + let getClient; + + const mockClient = (resolveFn) => { + getClient = jest.spyOn( + auth, + 'getAuthenticatedHttpClient', + ).mockReturnValue({ + get: jest.fn().mockReturnValue(new Promise(resolveFn)), + }); + }; + + const testFetch = ( + resolveFn, + expectedActions, + ) => { + const store = mockStore({}); + mockClient(resolveFn); + return store.dispatch(thunkActions.fetchPrevNextGrades( + args.endpoint, + courseId, + args.cohort, + args.track, + args.assignmentType, + )).then(() => { + expect(store.getActions()).toEqual(expectedActions); + }); + }; + + afterEach(() => { + getClient.mockRestore(); + }); + + describe('valid data', () => { + it('sends finished action and loads results', () => testFetch( + (resolve) => resolve({ data: response }), + [ + fetching.started(), + fetching.received({ + grades: gradesResults.sort(sortAlphaAsc), + prev: response.previous, + next: response.next, + totalUsersCount: response.total_users_count, + filteredUsersCount: response.filtered_users_count, + cohort: args.cohort, + track: args.track, + assignmentType: args.assignmentType, + courseId, + }), + fetching.finished(), + ], + )); + }); + describe('error response', () => { + it('sends error action', () => testFetch( + (resolve, reject) => reject(), + [fetching.started(), fetching.error()], + )); + }); + }); + + describe('submitFileUploadFormData', () => { + const formData = { form: 'data' }; + const testFetch = createTestFetcher( + LmsApiService.uploadGradeCsv, + thunkActions.submitFileUploadFormData, + [courseId, formData], + () => expect(LmsApiService.uploadGradeCsv).toHaveBeenCalledWith(courseId, formData), + ); + const { csvUpload, uploadOverride } = actions.grades; + describe('valid data', () => { + it('sends csvUpload finished and uploadOverride success actions', () => { + testFetch( + (resolve) => resolve(), + [ + csvUpload.started(), + csvUpload.finished(), + uploadOverride.success(courseId), + ], + ); + }); + }); + describe('error response', () => { + describe('non-200 error', () => { + it('sends uploadOverride failure w/ raw error and csvUploadError with default', () => { + const error = { some: 'error' }; + testFetch((resolve, reject) => reject(error), [ + csvUpload.started(), + uploadOverride.failure(courseId, error), + csvUpload.error({ errorMessages: ['Unknown error.'] }), + ]); + }); + }); + describe('200 error with no messages', () => { + it('sends uploadOverride failure w/ raw error and csvUploadError with default', () => { + const error = { status: 200, data: { error_messages: [] }, some: 'error' }; + testFetch((resolve, reject) => reject(error), [ + csvUpload.started(), + uploadOverride.failure(courseId, error), + csvUpload.error({ errorMessages: ['Unknown error.'] }), + ]); + }); + }); + describe('200 error with messages', () => { + it('sends uploadOverride failure w/ raw error and csvUploadError w loaded error', () => { + const error = { + status: 200, + data: { + error_messages: ['some', 'errors'], saved: 21, total: 32, + }, + }; + testFetch((resolve, reject) => reject(error), [ + csvUpload.started(), + uploadOverride.failure(courseId, error), + csvUpload.error({ + errorMessages: error.data.error_messages, + saved: error.data.saved, + total: error.data.total, + }), + ]); + }); + }); + }); + }); +}); diff --git a/src/data/thunkActions/index.js b/src/data/thunkActions/index.js new file mode 100644 index 0000000..3b50432 --- /dev/null +++ b/src/data/thunkActions/index.js @@ -0,0 +1,16 @@ +import { StrictDict } from 'utils'; +import assignmentTypes from './assignmentTypes'; +import cohorts from './cohorts'; +import filters from './filters'; +import grades from './grades'; +import roles from './roles'; +import tracks from './tracks'; + +export default StrictDict({ + assignmentTypes, + cohorts, + filters, + grades, + roles, + tracks, +}); diff --git a/src/data/thunkActions/roles.js b/src/data/thunkActions/roles.js new file mode 100644 index 0000000..ca22e28 --- /dev/null +++ b/src/data/thunkActions/roles.js @@ -0,0 +1,43 @@ +/* eslint-disable import/prefer-default-export */ +import { StrictDict } from 'utils'; +import roles from '../actions/roles'; +import selectors from '../selectors'; + +import { fetchCohorts } from './cohorts'; +import { + fetchGrades, +} from './grades'; +import { fetchTracks } from './tracks'; +import { fetchAssignmentTypes } from './assignmentTypes'; + +import LmsApiService from '../services/LmsApiService'; + +export const allowedRoles = ['staff', 'instructor', 'support']; + +export const fetchRoles = courseId => ( + (dispatch, getState) => LmsApiService.fetchUserRoles(courseId) + .then(response => response.data) + .then((response) => { + const isAllowedRole = (role) => ( + (role.course_id === courseId) && allowedRoles.includes(role.role) + ); + const canUserViewGradebook = (response.is_staff || (response.roles.some(isAllowedRole))); + + dispatch(roles.fetching.received({ canUserViewGradebook, courseId })); + + const { cohort, track, assignmentType } = selectors.filters.allFilters(getState()); + if (canUserViewGradebook) { + dispatch(fetchGrades(courseId, cohort, track, assignmentType)); + dispatch(fetchTracks(courseId)); + dispatch(fetchCohorts(courseId)); + dispatch(fetchAssignmentTypes(courseId)); + } + }) + .catch(() => { + dispatch(roles.fetching.error()); + })); + +export default StrictDict({ + allowedRoles, + fetchRoles, +}); diff --git a/src/data/thunkActions/roles.test.js b/src/data/thunkActions/roles.test.js new file mode 100644 index 0000000..96714b0 --- /dev/null +++ b/src/data/thunkActions/roles.test.js @@ -0,0 +1,112 @@ +import { createTestFetcher } from './testUtils'; + +import LmsApiService from '../services/LmsApiService'; +import actions from '../actions'; +import selectors from '../selectors'; + +import { fetchAssignmentTypes } from './assignmentTypes'; +import { fetchCohorts } from './cohorts'; +import { fetchGrades } from './grades'; +import { fetchTracks } from './tracks'; + +import { allowedRoles, fetchRoles } from './roles'; + +jest.mock('../selectors', () => ({ + __esModule: true, + default: { + filters: { + allFilters: jest.fn(), + }, + }, +})); +jest.mock('../services/LmsApiService', () => ({ + fetchUserRoles: jest.fn(), +})); +jest.mock('./assignmentTypes', () => ({ + fetchAssignmentTypes: jest.fn((...args) => ({ type: 'fetchAssignmentTypes', args })), +})); +jest.mock('./cohorts', () => ({ + fetchCohorts: jest.fn((...args) => ({ type: 'fetchCohorts', args })), +})); +jest.mock('./grades', () => ({ + fetchGrades: jest.fn((...args) => ({ type: 'fetchGrades', args })), +})); +jest.mock('./tracks', () => ({ + fetchTracks: jest.fn((...args) => ({ type: 'fetchTracks', args })), +})); + +const courseId = 'course-v1:edX+DemoX+Demo_Course'; +const allowedRole = { course_id: courseId, role: allowedRoles[0] }; +const responseData = { + roles: [ + { course_id: 'fakeCourseId', role: 'fakeROLE' }, + { couse_id: 'anotherId', role: 'STuff' }, + ], + is_staff: false, +}; + +describe('roles thunkActions', () => { + const filters = { + cohort: 'COHort', + track: 'traCK', + assignmentType: 23, + }; + beforeAll(() => { + selectors.filters.allFilters.mockReturnValue(filters); + }); + describe('fetchRoles', () => { + const testFetch = createTestFetcher( + LmsApiService.fetchUserRoles, + fetchRoles, + [courseId], + ); + describe('valid response', () => { + describe('cannot view gradebook (not is_staff, and no allowed roles)', () => { + it('dispatches received with canUserViewGradeBook=false and the courseId', () => ( + testFetch((resolve) => resolve({ data: responseData }), [ + actions.roles.fetching.received({ + canUserViewGradebook: false, + courseId, + }), + ]) + )); + }); + describe('canUserViewGradebook (is_staff or some role is allowed)', () => { + const testCanUserViewGradebookOutput = (resolveData) => { + const resolveFn = (resolve) => resolve({ data: resolveData }); + const expectedActions = [ + 'received with canUserViewGradebook=false and the courseId', + 'fetchGrades thunkAction with courseId and filters(cohort, track, and assignmentType)', + 'fetchTracks thunkAction with courseId', + 'fetchCohorts thunkAction with courseId', + 'fetchAssignmentTypes thunkAction with courseId', + ]; + it(`dispatches the appropriate actions: [\n ${expectedActions.join('\n ')}\n]`, () => testFetch( + resolveFn, + [ + actions.roles.fetching.received({ canUserViewGradebook: true, courseId }), + fetchGrades(courseId, filters.cohort, filters.track, filters.assignmentType), + fetchTracks(courseId), + fetchCohorts(courseId), + fetchAssignmentTypes(courseId), + ], + )); + }; + describe('is_staff', () => testCanUserViewGradebookOutput({ + ...responseData, + is_staff: true, + })); + describe('has allowed role', () => testCanUserViewGradebookOutput({ + ...responseData, + roles: [...responseData.roles, allowedRole], + })); + }); + }); + describe('actions dispatched on api error', () => { + test('errorFetching', () => testFetch( + (resolve, reject) => reject(), + [actions.roles.fetching.error()], + )); + }); + }); +}); diff --git a/src/data/thunkActions/testUtils.js b/src/data/thunkActions/testUtils.js new file mode 100644 index 0000000..77c6f43 --- /dev/null +++ b/src/data/thunkActions/testUtils.js @@ -0,0 +1,55 @@ +/* eslint-disable import/no-extraneous-dependencies */ +import configureMockStore from 'redux-mock-store'; +import thunk from 'redux-thunk'; + +const mockStore = configureMockStore([thunk]); + +/** createTestFetcher(mockedMethod, thunkAction, args, onDispatch) + * Creates a testFetch method, which will test a given thunkAction of the form: + * ``` + * const = () => (dispatch, getState) => { + * ... + * return .then().catch(); + * ``` + * The returned function will take a promise handler function, a list of expected actions + * to have been dispatched (objects only), and an optional verifyFn method to be called after + * the fetch has been completed. + * + * @param {fn} mockedMethod - already-mocked api method being exercised by the thunkAction. + * @param {fn} thunkAction - thunkAction to call/test + * @param {array} args - array of args to dispatch the thunkAction with + * @param {[fn]} onDispatch - optional function to be called after dispatch + * + * @return {fn} testFetch method + * @param {fn} resolveFn - promise handler of the form (resolve, reject) => {}. + * should return a call to resolve or reject with response data. + * @param {object[]} expectedActions - array of action objects expected to have been dispatched + * will be verified after the thunkAction resolves + * @param {[fn]} verifyFn - optional function to be called after dispatch + */ +export const createTestFetcher = ( + mockedMethod, + thunkAction, + args, + onDispatch, +) => ( + resolveFn, + expectedActions, + verifyFn, +) => { + const store = mockStore({}); + mockedMethod.mockReturnValue(new Promise(resolve => { + resolve(new Promise(resolveFn)); + })); + return store.dispatch(thunkAction(...args)).then(() => { + if (onDispatch) { onDispatch(); } + if (verifyFn) { verifyFn(); } + if (expectedActions !== undefined) { + expect(store.getActions()).toEqual(expectedActions); + } + }); +}; + +export default { + createTestFetcher, +}; diff --git a/src/data/thunkActions/tracks.js b/src/data/thunkActions/tracks.js new file mode 100644 index 0000000..1e9c3b5 --- /dev/null +++ b/src/data/thunkActions/tracks.js @@ -0,0 +1,30 @@ +/* eslint-disable import/prefer-default-export */ +import { StrictDict } from 'utils'; +import tracks from '../actions/tracks'; + +import selectors from '../selectors'; + +import { fetchBulkUpgradeHistory } from './grades'; + +import LmsApiService from '../services/LmsApiService'; + +export const fetchTracks = courseId => ( + (dispatch) => { + dispatch(tracks.fetching.started()); + return LmsApiService.fetchTracks(courseId) + .then(response => response.data) + .then((data) => { + dispatch(tracks.fetching.received(data.course_modes)); + if (selectors.tracks.hasMastersTrack(data.course_modes)) { + dispatch(fetchBulkUpgradeHistory(courseId)); + } + }) + .catch(() => { + dispatch(tracks.fetching.error()); + }); + } +); + +export default StrictDict({ + fetchTracks, +}); diff --git a/src/data/thunkActions/tracks.test.js b/src/data/thunkActions/tracks.test.js new file mode 100644 index 0000000..e6e71a3 --- /dev/null +++ b/src/data/thunkActions/tracks.test.js @@ -0,0 +1,85 @@ +import { createTestFetcher } from './testUtils'; + +import LmsApiService from '../services/LmsApiService'; +import actions from '../actions'; +import selectors from '../selectors'; + +import { fetchBulkUpgradeHistory } from './grades'; +import { fetchTracks } from './tracks'; + +jest.mock('../services/LmsApiService', () => ({ + fetchTracks: jest.fn(), +})); +jest.mock('../selectors', () => ({ + __esModule: true, + default: { + tracks: { hasMastersTrack: jest.fn(() => false) }, + }, +})); +jest.mock('./grades', () => ({ + fetchBulkUpgradeHistory: jest.fn((...args) => ({ type: 'fetchBulkUpgradeHistory', args })), +})); + +const courseId = 'course-v1:edX+DemoX+Demo_Course'; +const responseData = { + couse_modes: ['some', 'course', 'modes'], +}; + +describe('tracjs thunkActions', () => { + describe('fetchTracks', () => { + const testFetch = createTestFetcher( + LmsApiService.fetchTracks, + fetchTracks, + [courseId], + ); + describe('valid response', () => { + describe('if not hasMastersTrack(data.course_modes)', () => { + describe('dispatched actions', () => { + beforeEach(() => { + selectors.tracks.hasMastersTrack.mockReturnValue(false); + }); + const expectedActions = [ + 'tracks.fetching.started', + 'tracks.fetching.received with course_modes', + ]; + it(`dispatches [${expectedActions.join(', ')}]`, () => testFetch( + (resolve) => resolve({ data: responseData }), + [ + actions.tracks.fetching.started(), + actions.tracks.fetching.received(responseData.course_modes), + ], + )); + }); + }); + describe('if hasMastersTrack(data.course_modes)', () => { + describe('dispatched actions', () => { + beforeEach(() => { + selectors.tracks.hasMastersTrack.mockReturnValue(true); + }); + const expectedActions = [ + 'fetching.started', + 'fetching.received with course_modes', + 'fetchBulkUpgradeHistory thunkAction with courseId', + ]; + test(`[${expectedActions.join(', ')}]`, () => testFetch( + (resolve) => resolve({ data: responseData }), + [ + actions.tracks.fetching.started(), + actions.tracks.fetching.received(responseData.course_modes), + fetchBulkUpgradeHistory(courseId), + ], + )); + }); + }); + }); + describe('actions dispatched on api error', () => { + test('errorFetching', () => testFetch( + (resolve, reject) => reject(), + [ + actions.tracks.fetching.started(), + actions.tracks.fetching.error(), + ], + )); + }); + }); +}); diff --git a/src/utils/StrictDict.js b/src/utils/StrictDict.js new file mode 100644 index 0000000..78a002d --- /dev/null +++ b/src/utils/StrictDict.js @@ -0,0 +1,39 @@ +/* eslint-disable no-console */ +import util from 'util'; + +const staticReturnOptions = [ + 'dict', + 'inspect', + Symbol.toStringTag, + util.inspect.custom, + Symbol.for('nodejs.util.inspect.custom'), +]; + +const strictGet = (target, name) => { + if (name === Symbol.toStringTag) { + return target; + } + if (name === 'length') { + return target.length; + } + if (staticReturnOptions.indexOf(name) >= 0) { + return target; + } + if (name === Symbol.iterator) { + return { ...target }; + } + + if (name in target || name === '_reactFragment') { + return target[name]; + } + + console.log(name.toString()); + console.error({ target, name }); + const e = Error(`invalid property "${name.toString()}"`); + console.error(e.stack); + return undefined; +}; + +const StrictDict = (dict) => new Proxy(dict, { get: strictGet }); + +export default StrictDict; diff --git a/src/utils/index.js b/src/utils/index.js new file mode 100644 index 0000000..5351f65 --- /dev/null +++ b/src/utils/index.js @@ -0,0 +1,2 @@ +/* eslint-disable import/prefer-default-export */ +export { default as StrictDict } from './StrictDict';