Compare commits

...

29 Commits

Author SHA1 Message Date
Ben Warzeski
6ae048d9f1 update version to 1.4.29 2021-05-25 15:06:02 -04:00
Ben Warzeski
f0f3212843 some cleanup 2021-05-25 10:31:03 -04:00
Ben Warzeski
3e7a79c3e1 fix assignment filtering 2021-05-25 10:31:03 -04:00
Ben Warzeski
0e4541b7e3 docstrings 2021-05-25 10:31:03 -04:00
Ben Warzeski
452b39ddc5 docstrings for test utils 2021-05-25 10:31:02 -04:00
Ben Warzeski
126787b50f strict selector export 2021-05-25 10:31:02 -04:00
Ben Warzeski
c295207ed2 add a bit of test coverage 2021-05-25 10:31:02 -04:00
Ben Warzeski
c0d4e3a8f3 update package-lock.json 2021-05-25 10:30:59 -04:00
Ben Warzeski
e7bfdb7c8d fix store action reference 2021-05-25 10:28:39 -04:00
Ben Warzeski
0736c44d80 selectors cleanup 2021-05-25 10:28:39 -04:00
Ben Warzeski
2335f4b9e6 actions testing and cleanup 2021-05-25 10:28:39 -04:00
Ben Warzeski
1385b1a31a assignment type actions tests 2021-05-25 10:28:38 -04:00
Ben Warzeski
7e0e286efe fix tests 2021-05-25 10:28:38 -04:00
Ben Warzeski
92035af5d7 a little bit of doc and syntax cleanup 2021-05-25 10:28:38 -04:00
Ben Warzeski
0220efcc0b linting 2021-05-25 10:28:38 -04:00
Ben Warzeski
4be9ba9aa4 component thunkAction reference updates 2021-05-25 10:28:38 -04:00
Ben Warzeski
9d6cf2e06b thunkActions tests 2021-05-25 10:28:38 -04:00
Ben Warzeski
38324a0fc9 testing 2021-05-25 10:28:38 -04:00
Ben Warzeski
ced162356a update actions to use redux toolkit action creators 2021-05-25 10:28:38 -04:00
Leangseu Kim
e8aca4fde2 remove unnecessary testing data
update
2021-05-25 10:28:38 -04:00
Leangseu Kim
b7004e6e86 reorder the test reducer's handler 2021-05-25 10:28:38 -04:00
Leangseu Kim
12a85abb96 update unit test 2021-05-25 10:28:38 -04:00
Leangseu Kim
dfe6dbae8f export reducers from initial state 2021-05-25 10:28:38 -04:00
Leangseu Kim
21ec5fbbe5 update unit testing for reducers 2021-05-25 10:28:38 -04:00
Leangseu Kim
83701acc16 update testing for reducers 2021-05-25 10:28:38 -04:00
Ben Warzeski
b6b431dc37 ready for testing 2021-05-25 10:28:38 -04:00
Ben Warzeski
751d6f4a42 add StrictDict 2021-05-25 10:28:38 -04:00
Ben Warzeski
b2a737e936 update actions to use redux toolkit action creators 2021-05-25 10:28:38 -04:00
Ben Warzeski
1f93215648 add redux-toolkit 2021-05-25 10:28:37 -04:00
92 changed files with 4398 additions and 2796 deletions

342
package-lock.json generated
View File

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

View File

@@ -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": {

View File

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

View File

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

View File

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

View File

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

View File

@@ -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', () => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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', () => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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', () => ({

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

19
src/data/actions/index.js Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,3 +0,0 @@
const GOT_BULK_MANAGEMENT_CONFIG = 'GOT_BULK_MANAGEMENT_CONFIG';
export default GOT_BULK_MANAGEMENT_CONFIG;

View File

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

View File

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

View File

@@ -1,7 +0,0 @@
const GOT_ROLES = 'GOT_ROLES';
const ERROR_FETCHING_ROLES = 'ERROR_FETCHING_ROLES';
export {
GOT_ROLES,
ERROR_FETCHING_ROLES,
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 <thunkAction> = (<args>) => (dispatch, getState) => {
* ...
* return <mockedMethod>.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,
};

View File

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

View File

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

39
src/utils/StrictDict.js Normal file
View File

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

2
src/utils/index.js Normal file
View File

@@ -0,0 +1,2 @@
/* eslint-disable import/prefer-default-export */
export { default as StrictDict } from './StrictDict';