component work

This commit is contained in:
Ben Warzeski
2020-10-19 10:49:41 -04:00
parent dbe3dfa323
commit ac56ab766b
10 changed files with 1139 additions and 697 deletions

1
.env
View File

@@ -1,4 +1,5 @@
NODE_ENV='production',
NODE_PATH=./src
BASE_URL=null,
LMS_BASE_URL=null,
LOGIN_URL=null,

2
.gitignore vendored
View File

@@ -12,3 +12,5 @@ dist/
### Emacs ###
*~
*.swo
*.swp

102
package-lock.json generated
View File

@@ -5294,7 +5294,8 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/get-stream/-/get-stream-3.0.0.tgz",
"integrity": "sha1-jpQ9E1jcN1VQVOy+LtsFqhdO3hQ=",
"dev": true
"dev": true,
"optional": true
},
"got": {
"version": "8.3.2",
@@ -5372,6 +5373,7 @@
"resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-2.0.1.tgz",
"integrity": "sha512-88em58dDVB/KzPEx1X0N3LwFfYZPyDc4B6eF38M1rk9VTZMbxXXgjugz8mmwpS9Ox4BDZ+t6t3QP5+/gazweIA==",
"dev": true,
"optional": true,
"requires": {
"p-finally": "^1.0.0"
}
@@ -5413,9 +5415,10 @@
},
"bl": {
"version": "1.2.2",
"resolved": "http://registry.npmjs.org/bl/-/bl-1.2.2.tgz",
"resolved": "https://registry.npmjs.org/bl/-/bl-1.2.2.tgz",
"integrity": "sha512-e8tQYnZodmebYDWGH7KMRvtzKXaJHx3BbilrgZCfvyLUYdKpK1t5PSPmpkny/SgiTSCnjfLW7v5rlONXVFkQEA==",
"dev": true,
"optional": true,
"requires": {
"readable-stream": "^2.3.5",
"safe-buffer": "^5.1.1"
@@ -5743,6 +5746,7 @@
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.6.0.tgz",
"integrity": "sha512-/gDYp/UtU0eA1ys8bOs9J6a+E/KWIY+DZ+Q2WESNUA0jFRsJOc0SNUO6xJ5SGA1xueg3NL65W6s+NY5l9cunuw==",
"dev": true,
"optional": true,
"requires": {
"base64-js": "^1.0.2",
"ieee754": "^1.1.4"
@@ -5753,6 +5757,7 @@
"resolved": "https://registry.npmjs.org/buffer-alloc/-/buffer-alloc-1.2.0.tgz",
"integrity": "sha512-CFsHQgjtW1UChdXgbyJGtnm+O/uLQeZdtbDo8mfUgYXCHSM1wgrVxXm6bSyrUuErEb+4sYVGCzASBRot7zyrow==",
"dev": true,
"optional": true,
"requires": {
"buffer-alloc-unsafe": "^1.1.0",
"buffer-fill": "^1.0.0"
@@ -5762,19 +5767,22 @@
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/buffer-alloc-unsafe/-/buffer-alloc-unsafe-1.1.0.tgz",
"integrity": "sha512-TEM2iMIEQdJ2yjPJoSIsldnleVaAk1oW3DBVUykyOLsEsFmEc9kn+SFFPz+gl54KQNxlDnAwCXosOS9Okx2xAg==",
"dev": true
"dev": true,
"optional": true
},
"buffer-crc32": {
"version": "0.2.13",
"resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz",
"integrity": "sha1-DTM+PwDqxQqhRUq9MO+MKl2ackI=",
"dev": true
"dev": true,
"optional": true
},
"buffer-fill": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/buffer-fill/-/buffer-fill-1.0.0.tgz",
"integrity": "sha1-+PeLdniYiO858gXNY39o5wISKyw=",
"dev": true
"dev": true,
"optional": true
},
"buffer-from": {
"version": "1.1.1",
@@ -6111,6 +6119,7 @@
"resolved": "https://registry.npmjs.org/caw/-/caw-2.0.1.tgz",
"integrity": "sha512-Cg8/ZSBEa8ZVY9HspcGUYaK63d/bN7rqS3CYCzEGUxuYv6UlmcjzDUz2fCFFHyTvUW5Pk0I+3hkA3iXlIj6guA==",
"dev": true,
"optional": true,
"requires": {
"get-proxy": "^2.0.0",
"isurl": "^1.0.0-alpha5",
@@ -6745,6 +6754,7 @@
"resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.12.tgz",
"integrity": "sha512-a1eOIcu8+7lUInge4Rpf/n4Krkf3Dd9lqhljRzII1/Zno/kRtUWnznPO3jOKBmTEktkt3fkxisUcivoj0ebzoA==",
"dev": true,
"optional": true,
"requires": {
"ini": "^1.3.4",
"proto-list": "~1.2.1"
@@ -7813,6 +7823,7 @@
"resolved": "https://registry.npmjs.org/decompress/-/decompress-4.2.1.tgz",
"integrity": "sha512-e48kc2IjU+2Zw8cTb6VZcJQ3lgVbS4uuB1TfCHbiZIP/haNXm+SVyhu+87jts5/3ROpd82GSVCoNs/z8l4ZOaQ==",
"dev": true,
"optional": true,
"requires": {
"decompress-tar": "^4.0.0",
"decompress-tarbz2": "^4.0.0",
@@ -7829,6 +7840,7 @@
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-1.3.0.tgz",
"integrity": "sha512-2w31R7SJtieJJnQtGc7RVL2StM2vGYVfqUOvUDxH6bC6aJTxPxTF0GnIgCyu7tjockiUWAYQRbxa7vKn34s5sQ==",
"dev": true,
"optional": true,
"requires": {
"pify": "^3.0.0"
},
@@ -7837,7 +7849,8 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz",
"integrity": "sha1-5aSs0sEB/fPZpNB/DbxNtJ3SgXY=",
"dev": true
"dev": true,
"optional": true
}
}
},
@@ -7845,7 +7858,8 @@
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz",
"integrity": "sha1-7RQaasBDqEnqWISY59yosVMw6Qw=",
"dev": true
"dev": true,
"optional": true
}
}
},
@@ -7863,6 +7877,7 @@
"resolved": "https://registry.npmjs.org/decompress-tar/-/decompress-tar-4.1.1.tgz",
"integrity": "sha512-JdJMaCrGpB5fESVyxwpCx4Jdj2AagLmv3y58Qy4GE6HMVjWz1FeVQk1Ct4Kye7PftcdOo/7U7UKzYBJgqnGeUQ==",
"dev": true,
"optional": true,
"requires": {
"file-type": "^5.2.0",
"is-stream": "^1.1.0",
@@ -7873,7 +7888,8 @@
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/file-type/-/file-type-5.2.0.tgz",
"integrity": "sha1-LdvqfHP/42No365J3DOMBYwritY=",
"dev": true
"dev": true,
"optional": true
}
}
},
@@ -7882,6 +7898,7 @@
"resolved": "https://registry.npmjs.org/decompress-tarbz2/-/decompress-tarbz2-4.1.1.tgz",
"integrity": "sha512-s88xLzf1r81ICXLAVQVzaN6ZmX4A6U4z2nMbOwobxkLoIIfjVMBg7TeguTUXkKeXni795B6y5rnvDw7rxhAq9A==",
"dev": true,
"optional": true,
"requires": {
"decompress-tar": "^4.1.0",
"file-type": "^6.1.0",
@@ -7894,7 +7911,8 @@
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/file-type/-/file-type-6.2.0.tgz",
"integrity": "sha512-YPcTBDV+2Tm0VqjybVd32MHdlEGAtuxS3VAYsumFokDSMG+ROT5wawGlnHDoz7bfMcMDt9hxuXvXwoKUx2fkOg==",
"dev": true
"dev": true,
"optional": true
}
}
},
@@ -7903,6 +7921,7 @@
"resolved": "https://registry.npmjs.org/decompress-targz/-/decompress-targz-4.1.1.tgz",
"integrity": "sha512-4z81Znfr6chWnRDNfFNqLwPvm4db3WuZkqV+UgXQzSngG3CEKdBkw5jrv3axjjL96glyiiKjsxJG3X6WBZwX3w==",
"dev": true,
"optional": true,
"requires": {
"decompress-tar": "^4.1.1",
"file-type": "^5.2.0",
@@ -7913,7 +7932,8 @@
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/file-type/-/file-type-5.2.0.tgz",
"integrity": "sha1-LdvqfHP/42No365J3DOMBYwritY=",
"dev": true
"dev": true,
"optional": true
}
}
},
@@ -7922,6 +7942,7 @@
"resolved": "https://registry.npmjs.org/decompress-unzip/-/decompress-unzip-4.0.1.tgz",
"integrity": "sha1-3qrM39FK6vhVePczroIQ+bSEj2k=",
"dev": true,
"optional": true,
"requires": {
"file-type": "^3.8.0",
"get-stream": "^2.2.0",
@@ -7933,13 +7954,15 @@
"version": "3.9.0",
"resolved": "http://registry.npmjs.org/file-type/-/file-type-3.9.0.tgz",
"integrity": "sha1-JXoHg4TR24CHvESdEH1SpSZyuek=",
"dev": true
"dev": true,
"optional": true
},
"get-stream": {
"version": "2.3.1",
"resolved": "http://registry.npmjs.org/get-stream/-/get-stream-2.3.1.tgz",
"integrity": "sha1-Xzj5PzRgCWZu4BUKBUFn+Rvdld4=",
"dev": true,
"optional": true,
"requires": {
"object-assign": "^4.0.1",
"pinkie-promise": "^2.0.0"
@@ -7949,7 +7972,8 @@
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz",
"integrity": "sha1-7RQaasBDqEnqWISY59yosVMw6Qw=",
"dev": true
"dev": true,
"optional": true
}
}
},
@@ -8398,7 +8422,8 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz",
"integrity": "sha1-5aSs0sEB/fPZpNB/DbxNtJ3SgXY=",
"dev": true
"dev": true,
"optional": true
}
}
},
@@ -9596,6 +9621,7 @@
"resolved": "https://registry.npmjs.org/ext-list/-/ext-list-2.2.2.tgz",
"integrity": "sha512-u+SQgsubraE6zItfVA0tBuCBhfU9ogSRnsvygI7wht9TS510oLkBRXBsqopeUG/GBOIQyKZO9wjTqIu/sf5zFA==",
"dev": true,
"optional": true,
"requires": {
"mime-db": "^1.28.0"
}
@@ -9605,6 +9631,7 @@
"resolved": "https://registry.npmjs.org/ext-name/-/ext-name-5.0.0.tgz",
"integrity": "sha512-yblEwXAbGv1VQDmow7s38W77hzAgJAO50ztBLMcUyUBfxv1HC+LGwtiEN+Co6LtlqT/5uwVOxsD4TNIilWhwdQ==",
"dev": true,
"optional": true,
"requires": {
"ext-list": "^2.0.0",
"sort-keys-length": "^1.0.0"
@@ -9839,6 +9866,7 @@
"resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz",
"integrity": "sha1-JcfInLH5B3+IkbvmHY85Dq4lbx4=",
"dev": true,
"optional": true,
"requires": {
"pend": "~1.2.0"
}
@@ -9919,13 +9947,15 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/filename-reserved-regex/-/filename-reserved-regex-2.0.0.tgz",
"integrity": "sha1-q/c9+rc10EVECr/qLZHzieu/oik=",
"dev": true
"dev": true,
"optional": true
},
"filenamify": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/filenamify/-/filenamify-2.1.0.tgz",
"integrity": "sha512-ICw7NTT6RsDp2rnYKVd8Fu4cr6ITzGy3+u4vUujPkabyaz+03F24NWEX7fs5fp+kBonlaqPH8fAO2NM+SXt/JA==",
"dev": true,
"optional": true,
"requires": {
"filename-reserved-regex": "^2.0.0",
"strip-outer": "^1.0.0",
@@ -10190,7 +10220,8 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
"integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
"dev": true
"dev": true,
"optional": true
},
"fs-extra": {
"version": "9.0.1",
@@ -10353,6 +10384,7 @@
"resolved": "https://registry.npmjs.org/get-proxy/-/get-proxy-2.1.0.tgz",
"integrity": "sha512-zmZIaQTWnNQb4R4fJUEp/FC51eZsc6EkErspy3xtIYStaq8EB/hDIWipxsal+E8rz0qD7f2sL/NA9Xee4RInJw==",
"dev": true,
"optional": true,
"requires": {
"npm-conf": "^1.1.0"
}
@@ -10660,7 +10692,8 @@
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/has-symbol-support-x/-/has-symbol-support-x-1.4.2.tgz",
"integrity": "sha512-3ToOva++HaW+eCpgqZrCfN51IPB+7bJNVT6CUATzueB5Heb8o6Nam0V3HG5dlDvZU1Gn5QLcbahiKw/XVk5JJw==",
"dev": true
"dev": true,
"optional": true
},
"has-symbols": {
"version": "1.0.1",
@@ -10672,6 +10705,7 @@
"resolved": "https://registry.npmjs.org/has-to-string-tag-x/-/has-to-string-tag-x-1.4.1.tgz",
"integrity": "sha512-vdbKfmw+3LoOYVr+mtxHaX5a96+0f3DljYd8JOqvOLsf5mw2Otda2qCDT9qRqLAhrjyQ0h7ual5nOiASpsGNFw==",
"dev": true,
"optional": true,
"requires": {
"has-symbol-support-x": "^1.4.1"
}
@@ -12149,7 +12183,8 @@
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/is-natural-number/-/is-natural-number-4.0.1.tgz",
"integrity": "sha1-q5124dtM7VHjXeDHLr7PCfc0zeg=",
"dev": true
"dev": true,
"optional": true
},
"is-npm": {
"version": "1.0.0",
@@ -12199,7 +12234,8 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/is-object/-/is-object-1.0.1.tgz",
"integrity": "sha1-iVJojF7C/9awPsyF52ngKQMINHA=",
"dev": true
"dev": true,
"optional": true
},
"is-path-cwd": {
"version": "2.2.0",
@@ -12510,6 +12546,7 @@
"resolved": "https://registry.npmjs.org/isurl/-/isurl-1.0.0.tgz",
"integrity": "sha512-1P/yWsxPlDtn7QeRD+ULKQPaIaN6yF368GZ2vDfv0AL0NwpStafjWCDDdn0k8wgFMWpVAqG7oJhxHnlud42i9w==",
"dev": true,
"optional": true,
"requires": {
"has-to-string-tag-x": "^1.2.0",
"is-object": "^1.0.1"
@@ -19629,6 +19666,7 @@
"resolved": "https://registry.npmjs.org/npm-conf/-/npm-conf-1.1.3.tgz",
"integrity": "sha512-Yic4bZHJOt9RCFbRP3GgpqhScOY4HH3V2P8yBj6CeYq118Qr+BLXqT2JvpJ00mryLESpgOxf5XlFv4ZjXxLScw==",
"dev": true,
"optional": true,
"requires": {
"config-chain": "^1.1.11",
"pify": "^3.0.0"
@@ -19638,7 +19676,8 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz",
"integrity": "sha1-5aSs0sEB/fPZpNB/DbxNtJ3SgXY=",
"dev": true
"dev": true,
"optional": true
}
}
},
@@ -20147,6 +20186,7 @@
"resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-1.2.1.tgz",
"integrity": "sha1-XrOzU7f86Z8QGhA4iAuwVOu+o4Y=",
"dev": true,
"optional": true,
"requires": {
"p-finally": "^1.0.0"
}
@@ -20376,7 +20416,8 @@
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz",
"integrity": "sha1-elfrVQpng/kRUzH89GY9XI4AelA=",
"dev": true
"dev": true,
"optional": true
},
"performance-now": {
"version": "2.1.0",
@@ -21472,7 +21513,8 @@
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz",
"integrity": "sha1-IS1b/hMYMGpCD2QCuOJv85ZHqEk=",
"dev": true
"dev": true,
"optional": true
},
"proxy-addr": {
"version": "2.0.6",
@@ -23485,6 +23527,7 @@
"resolved": "https://registry.npmjs.org/seek-bzip/-/seek-bzip-1.0.6.tgz",
"integrity": "sha512-e1QtP3YL5tWww8uKaOCQ18UxIT2laNBXHjV/S2WYCiK4udiv8lkG89KRIoCjUagnAmCBurjF4zEVX2ByBbnCjQ==",
"dev": true,
"optional": true,
"requires": {
"commander": "^2.8.1"
},
@@ -23493,7 +23536,8 @@
"version": "2.20.3",
"resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
"integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
"dev": true
"dev": true,
"optional": true
}
}
},
@@ -24280,6 +24324,7 @@
"resolved": "https://registry.npmjs.org/sort-keys-length/-/sort-keys-length-1.0.1.tgz",
"integrity": "sha1-nLb09OnkgVWmqgZx7dM2/xR5oYg=",
"dev": true,
"optional": true,
"requires": {
"sort-keys": "^1.0.0"
}
@@ -24847,6 +24892,7 @@
"resolved": "https://registry.npmjs.org/strip-dirs/-/strip-dirs-2.1.0.tgz",
"integrity": "sha512-JOCxOeKLm2CAS73y/U4ZeZPTkE+gNVCzKt7Eox84Iej1LT/2pTWYpZKJuxwQpvX1LiZb1xokNR7RLfuBAa7T3g==",
"dev": true,
"optional": true,
"requires": {
"is-natural-number": "^4.0.1"
}
@@ -24882,6 +24928,7 @@
"resolved": "https://registry.npmjs.org/strip-outer/-/strip-outer-1.0.1.tgz",
"integrity": "sha512-k55yxKHwaXnpYGsOzg4Vl8+tDrWylxDEpknGjhTiZB8dFRU5rTo9CAzeycivxV3s+zlTKwrs6WxMxR95n26kwg==",
"dev": true,
"optional": true,
"requires": {
"escape-string-regexp": "^1.0.2"
}
@@ -25303,6 +25350,7 @@
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-1.6.2.tgz",
"integrity": "sha512-rzS0heiNf8Xn7/mpdSVVSMAWAoy9bfb1WOTYC78Z0UQKeKa/CWS8FOq0lKGNa8DWKAn9gxjCvMLYc5PGXYlK2A==",
"dev": true,
"optional": true,
"requires": {
"bl": "^1.0.0",
"buffer-alloc": "^1.2.0",
@@ -25337,6 +25385,7 @@
"resolved": "https://registry.npmjs.org/tempfile/-/tempfile-2.0.0.tgz",
"integrity": "sha1-awRGhWqbERTRhW/8vlCczLCXcmU=",
"dev": true,
"optional": true,
"requires": {
"temp-dir": "^1.0.0",
"uuid": "^3.0.1"
@@ -25576,7 +25625,8 @@
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/to-buffer/-/to-buffer-1.1.1.tgz",
"integrity": "sha512-lx9B5iv7msuFYE3dytT+KE5tap+rNYw+K4jVkb9R/asAb+pbBSM17jtunHplhBe6RRJdZx3Pn2Jph24O32mOVg==",
"dev": true
"dev": true,
"optional": true
},
"to-fast-properties": {
"version": "2.0.0",
@@ -25989,6 +26039,7 @@
"resolved": "https://registry.npmjs.org/trim-repeated/-/trim-repeated-1.0.0.tgz",
"integrity": "sha1-42RqLqTokTEr9+rObPsFOAvAHCE=",
"dev": true,
"optional": true,
"requires": {
"escape-string-regexp": "^1.0.2"
}
@@ -26102,6 +26153,7 @@
"resolved": "https://registry.npmjs.org/unbzip2-stream/-/unbzip2-stream-1.4.3.tgz",
"integrity": "sha512-mlExGW4w71ebDJviH16lQLtZS32VKqsSfk80GCfUlwT/4/hNRFsoscrF/c++9xinkMzECL1uL9DDwXqFWkruPg==",
"dev": true,
"optional": true,
"requires": {
"buffer": "^5.2.1",
"through": "^2.3.8"
@@ -26395,7 +26447,8 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/url-to-options/-/url-to-options-1.0.1.tgz",
"integrity": "sha1-FQWgOiiaSMvXpDTvuu7FBV9WM6k=",
"dev": true
"dev": true,
"optional": true
},
"urlgrey": {
"version": "0.4.4",
@@ -27766,6 +27819,7 @@
"resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz",
"integrity": "sha1-x+sXyT4RLLEIb6bY5R+wZnt5pfk=",
"dev": true,
"optional": true,
"requires": {
"buffer-crc32": "~0.2.3",
"fd-slicer": "~1.1.0"

View File

@@ -0,0 +1,206 @@
/* eslint-disable react/sort-comp, react/button-has-type */
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import {
Button,
Collapsible,
InputSelect,
InputText,
} from '@edx/paragon';
import queryString from 'query-string';
import { selectableAssignmentLabels } from '../../data/selectors/filters';
import {
filterAssignmentType,
fetchGrades,
updateGradesIfAssignmentGradeFiltersSet,
} from '../../data/actions/grades';
import {
updateAssignmentFilter,
updateAssignmentLimits,
} from '../../data/actions/filters';
const DECIMAL_PRECISION = 2;
const GRADE_OVERRIDE_HISTORY_COLUMNS = [{ label: 'Date', key: 'date' }, { label: 'Grader', key: 'grader' },
{ label: 'Reason', key: 'reason' },
{ label: 'Adjusted grade', key: 'adjustedGrade' }];
export class Assignments extends React.Component {
getAssignmentFilterOptions = () => [
{ label: 'All', value: '' },
...this.props.assignmentFilterOptions.map(({ label, subsectionLabel }) => ({
label: `${label}: ${subsectionLabel}`,
value: label,
})),
];
handleAssignmentFilterChange = (assignment) => {
const selectedFilterOption = this.props.assignmentFilterOptions.find(assig => assig.label === assignment);
const { type, id } = selectedFilterOption || {};
const typedValue = { label: assignment, type, id };
this.props.updateAssignmentFilter(typedValue);
this.updateQueryParams({ assignment: id });
this.props.updateGradesIfAssignmentGradeFiltersSet(
this.props.courseId,
this.props.selectedCohort,
this.props.selectedTrack,
this.props.selectedAssignmentType,
);
};
handleSubmitAssignmentGrade = (event) => {
event.preventDefault();
const {
assignmentGradeMin,
assignmentGradeMax,
} = this.props;
this.props.updateAssignmentLimits(assignmentGradeMin, assignmentGradeMax);
this.props.getUserGrades(
this.props.courseId,
this.props.selectedCohort,
this.props.selectedTrack,
this.props.selectedAssignmentType,
);
this.props.updateQueryParams({ assignmentGradeMin, assignmentGradeMax });
};
mapAssignmentTypeEntries = (entries) => {
const mapped = [
{ id: 0, label: 'All', value: '' },
...entries.map(entry => ({ id: entry, label: entry })),
];
return mapped;
};
updateAssignmentTypes = (assignmentType) => {
this.props.filterAssignmentType(assignmentType);
this.updateQueryParams({ assignmentType });
}
render() {
return (
<Collapsible title="Assignments" open className="filter-group mb-3">
<div>
<div className="student-filters">
<span className="label">
Assignment Types:
</span>
<InputSelect
label="Assignment Types"
name="assignment-types"
aria-label="Assignment Types"
value={this.props.selectedAssignmentType}
options={this.mapAssignmentTypeEntries(this.props.assignmentTypes)}
onChange={this.updateAssignmentTypes}
disabled={this.props.assignmentFilterOptions.length === 0}
/>
</div>
<div className="student-filters">
<span className="label">
Assignment:
</span>
<InputSelect
label="Assignment"
name="assignment"
aria-label="Assignment"
value={this.props.selectedAssignment}
options={this.getAssignmentFilterOptions()}
onChange={this.handleAssignmentFilterChange}
disabled={this.props.assignmentFilterOptions.length === 0}
/>
</div>
<p>Grade Range (0% - 100%)</p>
<form className="d-fnlex justify-content-between align-items-center" onSubmit={this.handleSubmitAssignmentGrade}>
<InputText
label="Min Grade"
name="assignmentGradeMin"
type="number"
min={0}
max={100}
step={1}
value={this.props.assignmentGradeMin}
disabled={!this.props.selectedAssignment}
onChange={this.props.setAssignmentGradeMax}
/>
<span className="input-percent-label">%</span>
<InputText
label="Max Grade"
name="assignmentGradeMax"
type="number"
min={0}
max={100}
step={1}
value={this.props.assignmentGradeMax}
disabled={!this.props.selectedAssignment}
onChange={this.props.setAssignmentGradeMax}
/>
<span className="input-percent-label">%</span>
<Button
type="submit"
className="btn-outline-secondary"
name="assignmentGradeMinMax"
disabled={!this.props.selectedAssignment}
>
Apply
</Button>
</form>
</div>
</Collapsible>
);
}
}
Assignments.defaultProps = {
assignmentTypes: [],
assignmentFilterOptions: [],
selectedAssignment: '',
selectedAssignmentType: '',
}
Assignments.propTypes = {
assignmentGradeMin: PropTypes.string.isRequired,
assignmentGradeMax: PropTypes.string.isRequired,
courseId: PropTypes.string.isRequired,
setAssignmentGradeMin: PropTypes.func.isRequired,
setAssignmentGradeMax: PropTypes.func.isRequired,
updateQueryParams: PropTypes.func.isRequired,
// redux
assignmentTypes: PropTypes.arrayOf(PropTypes.string),
assignmentFilterOptions: PropTypes.arrayOf(PropTypes.shape({
label: PropTypes.string,
subsectionLabel: PropTypes.string,
})),
filterAssignmentType: PropTypes.func.isRequired,
selectedAssignmentType: PropTypes.string,
selectedAssignment: PropTypes.string,
selectedCohort: PropTypes.string,
selectedTrack: PropTypes.string,
updateGradesIfAssignmentGradeFiltersSet: PropTypes.func.isRequired,
updateAssignmentFilter: PropTypes.func.isRequired,
updateAssignmentLimits: PropTypes.func.isRequired,
};
export const mapStateToProps = (state) => ({
assignmentTypes: state.assignmentTypes.results,
assignmentFilterOptions: selectableAssignmentLabels(state),
selectedAssignment: (state.filters.assignment || {}).label,
selectedAssignmentTypes: state.filters.assignmentType,
selectedCohort: state.filters.cohort,
selectedTrack: state.filters.track,
});
export const mapDispatchToProps = {
fetchGrades,
filterAssignmentType,
updateAssignmentFilter,
updateAssignmentLimits,
updateGradesIfAssignmentGradeFiltersSet,
}
export default connect(mapStateToProps, mapDispatchToProps)(Assignments);

View File

@@ -0,0 +1,200 @@
/* eslint-disable react/sort-comp, react/button-has-type */
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import {
Button,
StatusAlert,
Table,
Tab,
} from '@edx/paragon';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faDownload } from '@fortawesome/free-solid-svg-icons';
import { configuration } from '../../config';
import { submitFileUploadFormData } from '../../data/actions/grades';
import { getBulkManagementHistory } from '../../data/selectors/grades';
export class BulkManagement extends React.Component {
constructor(props) {
super(props);
this.fileFormRef = React.createRef();
this.fileInputRef = React.createRef();
}
formatHistoryRow = (row) => {
const {
summaryOfRowsProcessed: {
total,
successfullyProcessed,
failed,
skipped,
},
unique_id: courseId,
originalFilename,
id,
user: username,
...rest
} = row;
const resultsText = [
`${total} Students: ${successfullyProcessed} processed`,
...(skipped > 0 ? [`${skipped} skipped`] : []),
...(failed > 0 ? [`${failed} failed`] : []),
].join(', ');
const resultsSummary = (
<a
href={`${configuration.LMS_BASE_URL}/api/bulk_grades/course/${courseId}/?error_id=${id}`}
target="_blank"
rel="noopener noreferrer"
>
<FontAwesomeIcon icon={faDownload} />
{resultsText}
</a>
);
const createWrappedCell = (text) => (<span className="wrap-text-in-cell">{text}</span>);
const filename = createWrappedCell(originalFilename);
const user = createWrappedCell(username);
return {
resultsSummary,
filename,
user,
...rest,
};
};
handleClickImportGrades = () => {
const fileInput = this.fileInputRef.current;
if (fileInput) {
fileInput.click();
}
};
handleFileInputChange = (event) => {
const fileInput = event.target;
const file = fileInput.files[0];
const form = this.fileFormRef.current;
if (file && form) {
const formData = new FormData(form);
this.props.submitFileUploadFormData(this.props.courseId, formData).then(() => {
fileInput.value = null;
});
}
};
render() {
return (
<Tab eventKey="bulk_management" title="Bulk Management">
<h4>Use this feature by downloading a CSV for bulk management,
overriding grades locally, and coming back here to upload.
</h4>
<form ref={this.fileFormRef} action={this.props.gradeExportUrl} method="post">
<StatusAlert
alertType="danger"
dialog={this.props.bulkImportError}
open={this.props.bulkImportError}
dismissible={false}
/>
<StatusAlert
alertType="success"
dialog="CSV processing. File uploads may take several minutes to complete"
open={this.props.uploadSuccess}
dismissible={false}
/>
<input
className="d-none"
type="file"
name="csv"
label="Upload Grade CSV"
onChange={this.handleFileInputChange}
ref={this.fileInputRef}
/>
</form>
<Button
variant="primary"
onClick={this.handleClickImportGrades}
>
Import Grades
</Button>
<p>
Results appear in the table below.<br />
Grade processing may take a few seconds.
</p>
<Table
data={this.props.bulkManagementHistory.map(this.formatHistoryRow)}
hasFixedColumnWidths
columns={[
{
key: 'filename',
label: 'Gradebook',
columnSortable: false,
width: 'col-5',
},
{
key: 'resultsSummary',
label: 'Download Summary',
columnSortable: false,
width: 'col',
},
{
key: 'user',
label: 'Who',
columnSortable: false,
width: 'col-1',
},
{
key: 'timeUploaded',
label: 'When',
columnSortable: false,
width: 'col',
},
]}
className="table-striped"
/>
</Tab>
);
}
}
BulkManagement.defaultProps = {
bulkImportError: '',
bulkManagementHistory: [],
uploadSuccess: false,
};
BulkManagement.propTypes = {
courseId: PropTypes.string,
gradeExportUrl: PropTypes.string.isRequired,
// redux
bulkImportError: PropTypes.string,
bulkManagementHistory: PropTypes.arrayOf(PropTypes.shape({
originalFilename: PropTypes.string.isRequired,
user: PropTypes.string.isRequired,
timeUploaded: PropTypes.string.isRequired,
summaryOfRowsProcessed: PropTypes.shape({
total: PropTypes.number.isRequired,
successfullyProcessed: PropTypes.number.isRequired,
failed: PropTypes.number.isRequired,
skipped: PropTypes.number.isRequired,
}).isRequired,
})),
submitFileUploadFormData: PropTypes.func.isRequired,
uploadSuccess: PropTypes.bool,
};
export const mapStateToProps = (state) => ({
bulkImportError: state.grades.bulkManagement
&& state.grades.bulkManagement.errorMessages
? `Errors while processing: ${state.grades.bulkManagement.errorMessages.join(', ')}`
: '',
bulkManagementHistory: getBulkManagementHistory(state),
uploadSuccess: !!(state.grades.bulkManagement && state.grades.bulkManagement.uploadSuccess),
});
export const mapDispatchToProps = {
submitFileUploadFormData,
}
export default connect(mapStateToProps, mapDispatchToProps)(BulkManagement);

View File

@@ -0,0 +1,87 @@
/* eslint-disable react/sort-comp, react/button-has-type */
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
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';
export class BulkManagementControls extends React.Component {
handleClickDownloadInterventions = () => {
this.props.downloadInterventionReport(this.props.courseId);
window.location = this.props.interventionExportUrl;
};
// At present, we don't store label and value in google analytics. By setting the label
// property of the below events, I want to verify that we can set the label of google anlatyics
// The following properties of a google analytics event are:
// category (used), name(used), lavel(not used), value(not used)
handleClickExportGrades = () => {
this.props.downloadBulkGradesReport(this.props.courseId);
window.location = this.props.gradeExportUrl;
};
render() {
return (
<div>
<StatefulButton
variant="outline-primary"
onClick={this.handleClickExportGrades}
state={this.props.showSpinner ? 'pending' : 'default'}
labels={{
default: 'Bulk Management',
pending: 'Bulk Management',
}}
icons={{
default: <FontAwesomeIcon className="mr-2" icon={faDownload} />,
pending: <FontAwesomeIcon className="fa-spin mr-2" icon={faSpinner} />,
}}
disabledStates={['pending']}
/>
<StatefulButton
variant="outline-primary"
onClick={this.handleClickDownloadInterventions}
state={this.props.showSpinner ? 'pending' : 'default'}
className="ml-2"
labels={{
default: 'Interventions*',
pending: 'Interventions*',
}}
icons={{
default: <FontAwesomeIcon className="mr-2" icon={faDownload} />,
pending: <FontAwesomeIcon className="fa-spin mr-2" icon={faSpinner} />,
}}
disabledStates={['pending']}
/>
</div>
);
}
}
BulkManagementControls.propTypes = {
courseId: PropTypes.string,
gradeExportUrl: PropTypes.string.isRequired,
interventionExportUrl: PropTypes.string.isRequired,
showSpinner: PropTypes.bool,
// redux
downloadBulkGradesReport: PropTypes.func.isRequired,
downloadInterventionReport: PropTypes.func.isRequired,
};
export const mapStateToProps = (state) => ({ });
export const mapDispatchToProps = {
downloadBulkGradesReport,
downloadInterventionReport,
};
export default connect(mapStateToProps, mapDispatchToProps)(BulkManagementControls);

View File

@@ -0,0 +1,214 @@
/* eslint-disable react/sort-comp, react/button-has-type */
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import {
Button,
Modal,
StatusAlert,
Table,
} from '@edx/paragon';
import {
doneViewingAssignment,
updateGrades
} from '../../data/actions/grades';
const GRADE_OVERRIDE_HISTORY_COLUMNS = [{ label: 'Date', key: 'date' }, { label: 'Grader', key: 'grader' },
{ label: 'Reason', key: 'reason' },
{ label: 'Adjusted grade', key: 'adjustedGrade' }];
export class EditModal extends React.Component {
constructor(props) {
super(props);
this.overrideReasonInput = React.createRef();
}
componentDidMount() {
this.overrideReasonInput.current.focus();
}
handleAdjustedGradeClick = () => {
this.props.updateGrades(
this.props.courseId, [
{
user_id: this.props.updateUserId,
usage_id: this.props.updateModuleId,
grade: {
earned_graded_override: this.props.adjustedGradeValue,
comment: this.props.reasonForChange,
},
},
],
this.props.filterValue,
this.props.selectedCohort,
this.props.selectedTrack,
);
this.closeAssignmentModal();
}
closeAssignmentModal = () => {
this.props.doneViewingAssignment();
this.props.setGradebookState({
adjustedGradePossible: '',
adjustedGradeValue: '',
modalOpen: false,
reasonForChange: '',
updateModuleId: null,
updateUserId: null,
});
};
render() {
return (
<Modal
open={this.props.open}
title="Edit Grades"
closeText="Cancel"
body={(
<div>
<div>
<div className="grade-history-header grade-history-assignment">Assignment: </div>
<div>{this.props.assignmentName}</div>
<div className="grade-history-header grade-history-student">Student: </div>
<div>{this.props.updateUserName}</div>
<div className="grade-history-header grade-history-original-grade">Original Grade: </div>
<div>{this.props.gradeOriginalEarnedGraded}</div>
<div className="grade-history-header grade-history-current-grade">Current Grade: </div>
<div>{this.props.gradeOverrideCurrentEarnedGradedOverride}</div>
</div>
<StatusAlert
alertType="danger"
dialog={this.props.gradeOverrideHistoryError}
open={!!this.props.gradeOverrideHistoryError}
dismissible={false}
/>
{!this.props.gradeOverrideHistoryError && (
<Table
columns={GRADE_OVERRIDE_HISTORY_COLUMNS}
data={[...this.props.gradeOverrides, {
date: this.props.todaysDate,
reason: (<input
type="text"
name="reasonForChange"
value={this.props.reasonForChange}
onChange={this.props.setReasonForChange}
ref={this.overrideReasonInput}
/>),
adjustedGrade: (
<span>
<input
type="text"
name="adjustedGradeValue"
value={this.props.adjustedGradeValue}
onChange={this.props.setAdjustedGradeValue}
/>
{(this.props.adjustedGradePossible || this.props.gradeOriginalPossibleGraded) && ' / '}
{this.props.adjustedGradePossible || this.props.gradeOriginalPossibleGraded}
</span>),
}]}
/>
)}
<div>Showing most recent actions (max 5). To see more, please contact
support.
</div>
<div>Note: Once you save, your changes will be visible to students.</div>
</div>
)}
buttons={[
<Button
variant="primary"
onClick={this.handleAdjustedGradeClick}
>
Save Grade
</Button>,
]}
onClose={this.closeAssignmentModal}
/>
);
}
}
EditModal.defaultProps = {
areGradesFrozen: false,
assignmentTypes: [],
assignmentFilterOptions: [],
canUserViewGradebook: false,
cohorts: [],
gradeOverrides: [],
gradeOverrideCurrentEarnedGradedOverride: null,
gradeOriginalEarnedGraded: null,
gradeOriginalPossibleGraded: null,
location: {
search: '',
},
courseId: '',
selectedCohort: null,
selectedTrack: null,
selectedAssignmentType: '',
selectedAssignment: '',
showSpinner: false,
tracks: [],
bulkImportError: '',
uploadSuccess: false,
showBulkManagement: false,
bulkManagementHistory: [],
gradeOverrideHistoryError: '',
totalUsersCount: null,
filteredUsersCount: null,
};
EditModal.propTypes = {
assignmentName: PropTypes.string,
adjustedGradePossible: PropTypes.string,
adjustedGradeValue: PropTypes.number,
courseId: PropTypes.string,
filterValue: PropTypes.string,
open: PropTypes.bool,
reasonForChange: PropTypes.string,
setAdjustedGradeValue: PropTypes.func,
setGradebookState: PropTypes.func,
setReasonForChange: PropTypes.func,
todaysDate: PropTypes.string,
updateModuleId: PropTypes.string,
updateUserId: PropTypes.string,
updateUserName: PropTypes.string,
// redux
doneViewingAssignment: PropTypes.func.isRequired,
gradeOverrideCurrentEarnedGradedOverride: PropTypes.number,
gradeOverrideHistoryError: PropTypes.string,
gradeOverrides: PropTypes.arrayOf(PropTypes.shape({
date: PropTypes.string,
grader: PropTypes.string,
reason: PropTypes.string,
adjustedGrade: PropTypes.number,
})),
gradeOriginalEarnedGraded: PropTypes.number,
gradeOriginalPossibleGraded: PropTypes.number,
selectedCohort: PropTypes.string,
selectedTrack: PropTypes.string,
updateGrades: PropTypes.func.isRequired,
};
export const mapStateToProps = (state) => ({
gradeOverrides: state.grades.gradeOverrideHistoryResults,
gradeOverrideCurrentEarnedGradedOverride: state.grades.gradeOverrideCurrentEarnedGradedOverride,
gradeOverrideHistoryError: state.grades.gradeOverrideHistoryError,
gradeOriginalEarnedGraded: state.grades.gradeOriginalEarnedGraded,
grdaeOriginalPossibleGraded: state.grades.grdaeOriginalPossibleGraded,
selectedCohort: state.filters.cohort,
selectedTrack: state.filters.track,
})
export const mapDispatchToProps = {
doneViewingAssignment,
updateGrades,
}
export default connect(mapStateToProps, mapDispatchToProps)(EditModal);

View File

@@ -0,0 +1,224 @@
/* eslint-disable react/sort-comp, react/button-has-type */
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { Table } from '@edx/paragon';
import { formatDateForDisplay } from '../../data/actions/utils';
import { getHeadings } from '../../data/selectors/grades';
import { fetchGradeOverrideHistory } from '../../data/actions/grades';
const DECIMAL_PRECISION = 2;
export class GradebookTable extends React.Component {
setNewModalState = (userEntry, subsection) => {
this.props.fetchGradeOverrideHistory(
subsection.module_id,
userEntry.user_id,
);
let adjustedGradePossible = '';
if (subsection.attempted) {
adjustedGradePossible = subsection.score_possible;
}
this.props.setGradebookState({
adjustedGradePossible,
adjustedGradeValue: '',
modalAssignmentName: `${subsection.subsection_name}`,
modalOpen: true,
reasonForChange: '',
todaysDate: formatDateForDisplay(new Date()),
updateModuleId: subsection.module_id,
updateUserId: userEntry.user_id,
updateUserName: userEntry.username,
});
}
getLearnerInformation = entry => (
<div>
<div>{entry.username}</div>
{entry.external_user_key && <div className="student-key">{entry.external_user_key}</div>}
</div>
)
roundGrade = percent => parseFloat((percent || 0).toFixed(DECIMAL_PRECISION));
formatter = {
percent: (entries, areGradesFrozen) => entries.map((entry) => {
const learnerInformation = this.getLearnerInformation(entry);
const results = {
Username: (
<div><span className="wrap-text-in-cell">{learnerInformation}</span></div>
),
Email: (
<span className="wrap-text-in-cell">{entry.email}</span>
),
};
const assignments = entry.section_breakdown
.reduce((acc, subsection) => {
if (areGradesFrozen) {
acc[subsection.label] = `${this.roundGrade(subsection.percent * 100)} %`;
} else {
acc[subsection.label] = (
<button
className="btn btn-header link-style grade-button"
onClick={() => this.setNewModalState(entry, subsection)}
>
{this.roundGrade(subsection.percent * 100)}%
</button>
);
}
return acc;
}, {});
const totals = { Total: `${this.roundGrade(entry.percent * 100)}%` };
return Object.assign(results, assignments, totals);
}),
absolute: (entries, areGradesFrozen) => entries.map((entry) => {
const learnerInformation = this.getLearnerInformation(entry);
const results = {
Username: (
<div><span className="wrap-text-in-cell">{learnerInformation}</span></div>
),
Email: (
<span className="wrap-text-in-cell">{entry.email}</span>
),
};
const assignments = entry.section_breakdown
.reduce((acc, subsection) => {
const scoreEarned = this.roundGrade(subsection.score_earned);
const scorePossible = this.roundGrade(subsection.score_possible);
let label = `${scoreEarned}`;
if (subsection.attempted) {
label = `${scoreEarned}/${scorePossible}`;
}
if (areGradesFrozen) {
acc[subsection.label] = label;
} else {
acc[subsection.label] = (
<button
className="btn btn-header link-style"
onClick={() => this.setNewModalState(entry, subsection)}
>
{label}
</button>
);
}
return acc;
}, {});
const totals = { Total: `${this.roundGrade(entry.percent * 100)}/100` };
return Object.assign(results, assignments, totals);
}),
};
formatHeadings = () => {
let headings = [...this.props.headings];
if (headings.length > 0) {
const userInformationHeadingLabel = (
<div>
<div>Username</div>
<div className="font-weight-normal student-key">Student Key*</div>
</div>
);
const emailHeadingLabel = 'Email*';
headings = headings.map(heading => ({ label: heading, key: heading, width: 'col-1' }));
// replace username heading label to include additional user data
headings[0].label = userInformationHeadingLabel;
headings[0].width = 'col-2';
headings[1].label = emailHeadingLabel;
headings[1].width = 'col-2';
}
return headings;
}
render() {
return (
<div className="gradebook-container">
<div className="gbook">
<Table
columns={this.formatHeadings()}
data={[
...this.formatter[this.props.format](
this.props.grades,
this.props.areGradesFrozen,
),
...this.formatter[this.props.format](
this.props.grades,
this.props.areGradesFrozen,
),
...this.formatter[this.props.format](
this.props.grades,
this.props.areGradesFrozen,
),
...this.formatter[this.props.format](
this.props.grades,
this.props.areGradesFrozen,
),
...this.formatter[this.props.format](
this.props.grades,
this.props.areGradesFrozen,
),
...this.formatter[this.props.format](
this.props.grades,
this.props.areGradesFrozen,
),
]}
rowHeaderColumnKey="username"
hasFixedColumnWidths
/>
</div>
</div>
);
}
}
GradebookTable.defaultProps = {
areGradesFrozen: false,
grades: [],
};
GradebookTable.propTypes = {
setGradebookState: PropTypes.func.isRequired,
// redux
areGradesFrozen: PropTypes.bool.isRequired,
format: PropTypes.string.isRequired,
grades: PropTypes.arrayOf(PropTypes.shape({
percent: PropTypes.number,
section_breakdown: PropTypes.arrayOf(PropTypes.shape({
attempted: PropTypes.bool,
category: PropTypes.string,
label: PropTypes.string,
module_id: PropTypes.string,
percent: PropTypes.number,
scoreEarned: PropTypes.number,
scorePossible: PropTypes.number,
subsection_name: PropTypes.string,
})),
user_id: PropTypes.number,
user_name: PropTypes.string,
})),
headings: PropTypes.arrayOf(PropTypes.string).isRequired,
fetchGradeOverrideHistory: PropTypes.func.isRequired,
};
export const mapStateToProps = (state) => ({
areGradesFrozen: state.assignmentTypes.areGradesFrozen,
format: state.grades.gradeFormat,
grades: state.grades.results,
headings: getHeadings(state),
});
export const mapDispatchToProps = {
fetchGradeOverrideHistory,
};
export default connect(mapStateToProps, mapDispatchToProps)(GradebookTable);

View File

@@ -25,30 +25,33 @@ import { formatDateForDisplay } from '../../data/actions/utils';
import initialFilters from '../../data/constants/filters';
import ConnectedFilterBadges from '../FilterBadges';
const DECIMAL_PRECISION = 2;
const GRADE_OVERRIDE_HISTORY_COLUMNS = [{ label: 'Date', key: 'date' }, { label: 'Grader', key: 'grader' },
{ label: 'Reason', key: 'reason' },
{ label: 'Adjusted grade', key: 'adjustedGrade' }];
import Assignments from './Assignments';
import BulkManagement from './BulkManagement';
import BulkManagementControls from './BulkManagementControls';
import EditModal from './EditModal';
import GradebookTable from './GradebookTable';
export default class Gradebook extends React.Component {
constructor(props) {
super(props);
this.state = {
filterValue: '',
courseGradeMin: '0',
courseGradeMax: '100',
modalOpen: false,
adjustedGradePossible: '',
adjustedGradeValue: 0,
updateModuleId: null,
updateUserId: null,
reasonForChange: '',
assignmentGradeMin: '0',
assignmentGradeMax: '100',
assignmentName: '',
courseGradeMin: '0',
courseGradeMax: '100',
filterValue: '',
isMinCourseGradeFilterValid: true,
isMaxCourseGradeFilterValid: true,
modalAssignmentName: '',
modalOpen: false,
reasonForChange: '',
todaysDate: '',
updateModuleId: null,
updateUserId: null,
};
this.fileFormRef = React.createRef();
this.fileInputRef = React.createRef();
this.myRef = React.createRef();
}
@@ -56,7 +59,6 @@ export default class Gradebook extends React.Component {
const urlQuery = queryString.parse(this.props.location.search);
this.props.initializeFilters(urlQuery);
this.props.getRoles(this.props.courseId);
this.overrideReasonInput.focus();
const newStateFields = {};
['assignmentGradeMin', 'assignmentGradeMax', 'courseGradeMin', 'courseGradeMax'].forEach((attr) => {
@@ -72,37 +74,6 @@ export default class Gradebook extends React.Component {
this.setState({ [e.target.name]: e.target.value });
}
setNewModalState = (userEntry, subsection) => {
this.props.fetchGradeOverrideHistory(
subsection.module_id,
userEntry.user_id,
);
let adjustedGradePossible = '';
if (subsection.attempted) {
adjustedGradePossible = subsection.score_possible;
}
this.setState({
modalAssignmentName: `${subsection.subsection_name}`,
modalOpen: true,
updateModuleId: subsection.module_id,
updateUserId: userEntry.user_id,
updateUserName: userEntry.username,
todaysDate: formatDateForDisplay(new Date()),
adjustedGradePossible,
reasonForChange: '',
adjustedGradeValue: '',
});
}
getLearnerInformation = entry => (
<div>
<div>{entry.username}</div>
{entry.external_user_key && <div className="student-key">{entry.external_user_key}</div>}
</div>
)
getActiveTabs = () => {
if (this.props.showBulkManagement) {
return ['Grades', 'Bulk Management'];
@@ -110,17 +81,6 @@ export default class Gradebook extends React.Component {
return ['Grades'];
};
getAssignmentFilterOptions = () => [
{ label: 'All', value: '' },
...this.props.assignmentFilterOptions.map((assignment) => {
const { label, subsectionLabel } = assignment;
return {
label: `${label}: ${subsectionLabel}`,
value: label,
};
}),
];
getCourseGradeFilterAlertDialog = () => {
let dialog = '';
@@ -133,52 +93,6 @@ export default class Gradebook extends React.Component {
return dialog;
};
handleAdjustedGradeClick = () => {
this.props.updateGrades(
this.props.courseId, [
{
user_id: this.state.updateUserId,
usage_id: this.state.updateModuleId,
grade: {
earned_graded_override: this.state.adjustedGradeValue,
comment: this.state.reasonForChange,
},
},
],
this.state.filterValue,
this.props.selectedCohort,
this.props.selectedTrack,
);
this.closeAssignmentModal();
}
closeAssignmentModal = () => {
this.props.doneViewingAssignment();
this.setState({
adjustedGradePossible: '',
adjustedGradeValue: '',
modalOpen: false,
reasonForChange: '',
updateModuleId: null,
updateUserId: null,
});
};
handleAssignmentFilterChange = (assignment) => {
const selectedFilterOption = this.props.assignmentFilterOptions.find(assig => assig.label === assignment);
const { type, id } = selectedFilterOption || {};
const typedValue = { label: assignment, type, id };
this.props.updateAssignmentFilter(typedValue);
this.updateQueryParams({ assignment: id });
this.props.updateGradesIfAssignmentGradeFiltersSet(
this.props.courseId,
this.props.selectedCohort,
this.props.selectedTrack,
this.props.selectedAssignmentType,
);
};
updateQueryParams = (queryParams) => {
const parsed = queryString.parse(this.props.location.search);
Object.keys(queryParams).forEach((key) => {
@@ -191,15 +105,6 @@ export default class Gradebook extends React.Component {
this.props.history.push(`?${queryString.stringify(parsed)}`);
};
mapAssignmentTypeEntries = (entries) => {
const mapped = entries.map(entry => ({
id: entry,
label: entry,
}));
mapped.unshift({ id: 0, label: 'All', value: '' });
return mapped;
};
mapCohortsEntries = (entries) => {
const mapped = entries.map(entry => ({
id: entry.id,
@@ -218,58 +123,6 @@ export default class Gradebook extends React.Component {
return mapped;
};
formatHistoryRow = (row) => {
const {
summaryOfRowsProcessed: {
total,
successfullyProcessed,
failed,
skipped,
},
unique_id: courseId,
originalFilename,
id,
user: username,
...rest
} = row;
const resultsText = [
`${total} Students: ${successfullyProcessed} processed`,
...(skipped > 0 ? [`${skipped} skipped`] : []),
...(failed > 0 ? [`${failed} failed`] : []),
].join(', ');
const resultsSummary = (
<a
href={`${configuration.LMS_BASE_URL}/api/bulk_grades/course/${courseId}/?error_id=${id}`}
target="_blank"
rel="noopener noreferrer"
>
<FontAwesomeIcon icon={faDownload} />
{resultsText}
</a>
);
const filename = (
<span className="wrap-text-in-cell">
{originalFilename}
</span>
);
const user = (
<span className="wrap-text-in-cell">
{username}
</span>
);
return {
resultsSummary,
filename,
user,
...rest,
};
};
updateAssignmentTypes = (assignmentType) => {
this.props.filterAssignmentType(assignmentType);
this.updateQueryParams({ assignmentType });
}
updateTracks = (event) => {
const selectedTrackItem = this.props.tracks.find(x => x.name === event);
let selectedTrackSlug = null;
@@ -300,60 +153,6 @@ export default class Gradebook extends React.Component {
this.updateQueryParams({ cohort: selectedCohortId });
};
// At present, we don't store label and value in google analytics. By setting the label
// property of the below events, I want to verify that we can set the label of google anlatyics
// The following properties of a google analytics event are:
// category (used), name(used), lavel(not used), value(not used)
handleClickExportGrades = () => {
this.props.downloadBulkGradesReport(this.props.courseId);
window.location = this.props.gradeExportUrl;
};
handleClickDownloadInterventions = () => {
this.props.downloadInterventionReport(this.props.courseId);
window.location = this.props.interventionExportUrl;
};
handleClickImportGrades = () => {
const fileInput = this.fileInputRef.current;
if (fileInput) {
fileInput.click();
}
};
handleFileInputChange = (event) => {
const fileInput = event.target;
const file = fileInput.files[0];
const form = this.fileFormRef.current;
if (file && form) {
const formData = new FormData(form);
this.props.submitFileUploadFormData(this.props.courseId, formData).then(() => {
fileInput.value = null;
});
}
};
handleSubmitAssignmentGrade = (event) => {
event.preventDefault();
const {
assignmentGradeMin,
assignmentGradeMax,
} = this.state;
this.props.updateAssignmentLimits(assignmentGradeMin, assignmentGradeMax);
this.props.getUserGrades(
this.props.courseId,
this.props.selectedCohort,
this.props.selectedTrack,
this.props.selectedAssignmentType,
);
this.updateQueryParams({ assignmentGradeMin, assignmentGradeMax });
};
handleMinAssigGradeChange = assignmentGradeMin => this.setState({ assignmentGradeMin });
handleMaxAssigGradeChange = assignmentGradeMax => this.setState({ assignmentGradeMax });
mapSelectedCohortEntry = (entry) => {
const selectedCohortEntry = this.props.cohorts.find(x => x.id === parseInt(entry, 10));
if (selectedCohortEntry) {
@@ -370,103 +169,8 @@ export default class Gradebook extends React.Component {
return 'Tracks';
};
roundGrade = percent => parseFloat((percent || 0).toFixed(DECIMAL_PRECISION));
formatter = {
percent: (entries, areGradesFrozen) => entries.map((entry) => {
const learnerInformation = this.getLearnerInformation(entry);
const results = {
Username: (
<div><span className="wrap-text-in-cell">{learnerInformation}</span></div>
),
Email: (
<span className="wrap-text-in-cell">{entry.email}</span>
),
};
const assignments = entry.section_breakdown
.reduce((acc, subsection) => {
if (areGradesFrozen) {
acc[subsection.label] = `${this.roundGrade(subsection.percent * 100)} %`;
} else {
acc[subsection.label] = (
<button
className="btn btn-header link-style grade-button"
onClick={() => this.setNewModalState(entry, subsection)}
>
{this.roundGrade(subsection.percent * 100)}%
</button>
);
}
return acc;
}, {});
const totals = { Total: `${this.roundGrade(entry.percent * 100)}%` };
return Object.assign(results, assignments, totals);
}),
absolute: (entries, areGradesFrozen) => entries.map((entry) => {
const learnerInformation = this.getLearnerInformation(entry);
const results = {
Username: (
<div><span className="wrap-text-in-cell">{learnerInformation}</span></div>
),
Email: (
<span className="wrap-text-in-cell">{entry.email}</span>
),
};
const assignments = entry.section_breakdown
.reduce((acc, subsection) => {
const scoreEarned = this.roundGrade(subsection.score_earned);
const scorePossible = this.roundGrade(subsection.score_possible);
let label = `${scoreEarned}`;
if (subsection.attempted) {
label = `${scoreEarned}/${scorePossible}`;
}
if (areGradesFrozen) {
acc[subsection.label] = label;
} else {
acc[subsection.label] = (
<button
className="btn btn-header link-style"
onClick={() => this.setNewModalState(entry, subsection)}
>
{label}
</button>
);
}
return acc;
}, {});
const totals = { Total: `${this.roundGrade(entry.percent * 100)}/100` };
return Object.assign(results, assignments, totals);
}),
};
lmsInstructorDashboardUrl = courseId => `${configuration.LMS_BASE_URL}/courses/${courseId}/instructor`;
formatHeadings = () => {
let headings = [...this.props.headings];
if (headings.length > 0) {
const userInformationHeadingLabel = (
<div>
<div>Username</div>
<div className="font-weight-normal student-key">Student Key*</div>
</div>
);
const emailHeadingLabel = 'Email*';
headings = headings.map(heading => ({ label: heading, key: heading }));
// replace username heading label to include additional user data
headings[0].label = userInformationHeadingLabel;
headings[1].label = emailHeadingLabel;
}
return headings;
}
handleCourseGradeFilterChange = (type, value) => {
const filterValue = value;
@@ -539,6 +243,26 @@ export default class Gradebook extends React.Component {
);
}
createStateFieldSetter = (key) => (value) => this.setState({ [key]: value });
createLimitedSetter = (...keys) => (values) => this.setState(
keys.reduce(
(obj, key) => ( values[key] === undefined ? obj : { ...obj, [key]: values[key] } ),
{}
)
)
safeSetState = this.createLimitedSetter(
'adjustedGradePossible',
'adjustedGradeValue',
'modalOpen',
'reasonForChange',
'todaysDate',
'updateModuleId',
'updateUserId',
'updateUserName',
);
render() {
return (
<Drawer
@@ -631,189 +355,42 @@ export default class Gradebook extends React.Component {
onChange={this.props.toggleFormat}
/>
{this.props.showBulkManagement && (
<div>
<StatefulButton
variant="outline-primary"
onClick={this.handleClickExportGrades}
state={this.props.showSpinner ? 'pending' : 'default'}
labels={{
default: 'Bulk Management',
pending: 'Bulk Management',
}}
icons={{
default: <FontAwesomeIcon className="mr-2" icon={faDownload} />,
pending: <FontAwesomeIcon className="fa-spin mr-2" icon={faSpinner} />,
}}
disabledStates={['pending']}
/>
<StatefulButton
variant="outline-primary"
onClick={this.handleClickDownloadInterventions}
state={this.props.showSpinner ? 'pending' : 'default'}
className="ml-2"
labels={{
default: 'Interventions*',
pending: 'Interventions*',
}}
icons={{
default: <FontAwesomeIcon className="mr-2" icon={faDownload} />,
pending: <FontAwesomeIcon className="fa-spin mr-2" icon={faSpinner} />,
}}
disabledStates={['pending']}
/>
</div>
<BulkManagementControls
courseId={this.props.courseId}
gradeExportUrl={this.props.gradeExportUrl}
interventionExportUrl={this.props.interventionExportUrl}
showSpinner={this.props.showSpinner}
/>
)}
</div>
<div className="gradebook-container">
<div className="gbook">
<Table
columns={this.formatHeadings()}
data={this.formatter[this.props.format](
this.props.grades,
this.props.areGradesFrozen,
)}
rowHeaderColumnKey="username"
hasFixedColumnWidths
/>
</div>
</div>
<GradebookTable setGradebookState={this.safeSetState} />
{PageButtons(this.props)}
<p>* available for learners in the Master&apos;s track only</p>
<Modal
<EditModal
assignmentName={this.state.assignmentName}
adjustedGradeValue={this.state.adjustedGradeValue}
adjustedGradePossible={this.state.adjustedGradePossible}
courseId={this.props.courseId}
filterValue={this.state.filterValue}
onChange={this.onChange}
open={this.state.modalOpen}
title="Edit Grades"
closeText="Cancel"
body={(
<div>
<div>
<div className="grade-history-header grade-history-assignment">Assignment: </div> <div>{this.state.modalAssignmentName}</div>
<div className="grade-history-header grade-history-student">Student: </div> <div>{this.state.updateUserName}</div>
<div className="grade-history-header grade-history-original-grade">Original Grade: </div> <div>{this.props.gradeOriginalEarnedGraded}</div>
<div className="grade-history-header grade-history-current-grade">Current Grade: </div> <div>{this.props.gradeOverrideCurrentEarnedGradedOverride}</div>
</div>
<StatusAlert
alertType="danger"
dialog={this.props.gradeOverrideHistoryError}
open={this.props.gradeOverrideHistoryError}
dismissible={false}
/>
{!this.props.gradeOverrideHistoryError && (
<Table
columns={GRADE_OVERRIDE_HISTORY_COLUMNS}
data={[...this.props.gradeOverrides, {
date: this.state.todaysDate,
reason: (<input
type="text"
name="reasonForChange"
value={this.state.reasonForChange}
onChange={value => this.onChange(value)}
ref={(input) => { this.overrideReasonInput = input; }}
/>),
adjustedGrade: (
<span>
<input
type="text"
name="adjustedGradeValue"
value={this.state.adjustedGradeValue}
onChange={value => this.onChange(value)}
/>
{(this.state.adjustedGradePossible
|| this.props.gradeOriginalPossibleGraded)
&& ' / '}
{this.state.adjustedGradePossible
|| this.props.gradeOriginalPossibleGraded}
</span>),
}]}
/>
)}
<div>Showing most recent actions (max 5). To see more, please contact
support.
</div>
<div>Note: Once you save, your changes will be visible to students.</div>
</div>
)}
buttons={[
<Button
variant="primary"
onClick={this.handleAdjustedGradeClick}
>
Save Grade
</Button>,
]}
onClose={this.closeAssignmentModal}
reasonForChange={this.state.reasonForChange}
setAdjustedGradeValue={this.createStateFieldSetter('adjustedGradeValue')}
setGradebookState={this.safeSetState}
setReasonForChange={this.createStateFieldSetter('reasonForChange')}
todaysDate={this.state.todaysDate}
updateModuleId={this.state.updateModuleId}
updateUserId={this.state.updateUserId}
updateUserName={this.state.updateUserName}
/>
</Tab>
{this.props.showBulkManagement && (
<Tab eventKey="bulk_management" title="Bulk Management">
<h4>Use this feature by downloading a CSV for bulk management,
overriding grades locally, and coming back here to upload.
</h4>
<form ref={this.fileFormRef} action={this.props.gradeExportUrl} method="post">
<StatusAlert
alertType="danger"
dialog={this.props.bulkImportError}
open={this.props.bulkImportError}
dismissible={false}
/>
<StatusAlert
alertType="success"
dialog="CSV processing. File uploads may take several minutes to complete"
open={this.props.uploadSuccess}
dismissible={false}
/>
<input
className="d-none"
type="file"
name="csv"
label="Upload Grade CSV"
onChange={this.handleFileInputChange}
ref={this.fileInputRef}
/>
</form>
<Button
variant="primary"
onClick={this.handleClickImportGrades}
>
Import Grades
</Button>
<p>
Results appear in the table below.<br />
Grade processing may take a few seconds.
</p>
<Table
data={this.props.bulkManagementHistory.map(this.formatHistoryRow)}
hasFixedColumnWidths
columns={[
{
key: 'filename',
label: 'Gradebook',
columnSortable: false,
width: 'col-5',
},
{
key: 'resultsSummary',
label: 'Download Summary',
columnSortable: false,
width: 'col',
},
{
key: 'user',
label: 'Who',
columnSortable: false,
width: 'col-1',
},
{
key: 'timeUploaded',
label: 'When',
columnSortable: false,
width: 'col',
},
]}
className="table-striped"
/>
</Tab>
)}
{this.props.showBulkManagement &&
<BulkManagement
courseId={this.props.courseId}
gradeExportUrl={this.props.gradeExportUrl}
/>
}
</Tabs>
</div>
)}
@@ -824,73 +401,14 @@ export default class Gradebook extends React.Component {
</>
)}
>
<Collapsible title="Assignments" open className="filter-group mb-3">
<div>
<div className="student-filters">
<span className="label">
Assignment Types:
</span>
<InputSelect
label="Assignment Types"
name="assignment-types"
aria-label="Assignment Types"
value={this.props.selectedAssignmentType}
options={this.mapAssignmentTypeEntries(this.props.assignmentTypes)}
onChange={this.updateAssignmentTypes}
disabled={this.props.assignmentFilterOptions.length === 0}
/>
</div>
<div className="student-filters">
<span className="label">
Assignment:
</span>
<InputSelect
label="Assignment"
name="assignment"
aria-label="Assignment"
value={this.props.selectedAssignment}
options={this.getAssignmentFilterOptions()}
onChange={this.handleAssignmentFilterChange}
disabled={this.props.assignmentFilterOptions.length === 0}
/>
</div>
<p>Grade Range (0% - 100%)</p>
<form className="d-flex justify-content-between align-items-center" onSubmit={this.handleSubmitAssignmentGrade}>
<InputText
label="Min Grade"
name="assignmentGradeMin"
type="number"
min={0}
max={100}
step={1}
value={this.state.assignmentGradeMin}
disabled={!this.props.selectedAssignment}
onChange={this.handleMinAssigGradeChange}
/>
<span className="input-percent-label">%</span>
<InputText
label="Max Grade"
name="assignmentGradeMax"
type="number"
min={0}
max={100}
step={1}
value={this.state.assignmentGradeMax}
disabled={!this.props.selectedAssignment}
onChange={this.handleMaxAssigGradeChange}
/>
<span className="input-percent-label">%</span>
<Button
type="submit"
className="btn-outline-secondary"
name="assignmentGradeMinMax"
disabled={!this.props.selectedAssignment}
>
Apply
</Button>
</form>
</div>
</Collapsible>
<Assignments
assignmentGradeMin={this.state.assignmentGradeMin}
assignmentGradeMax={this.state.assignmentGradeMax}
courseId={this.props.courseId}
setAssignmentGradeMin={this.createStateFieldSetter('assignmentGradeMin')}
setAssignmentGradeMax={this.createStateFieldSetter('assignmentGradeMax')}
updateQueryParams={this.updateQueryParams}
/>
<Collapsible title="Overall Grade" open className="filter-group mb-3">
<div className="d-flex justify-content-between align-items-center">
<InputText
@@ -948,53 +466,38 @@ export default class Gradebook extends React.Component {
Gradebook.defaultProps = {
areGradesFrozen: false,
assignmentTypes: [],
assignmentFilterOptions: [],
canUserViewGradebook: false,
cohorts: [],
courseId: '',
filteredUsersCount: null,
grades: [],
gradeOverrides: [],
gradeOverrideCurrentEarnedGradedOverride: null,
gradeOriginalEarnedGraded: null,
gradeOriginalPossibleGraded: null,
location: {
search: '',
},
courseId: '',
selectedAssignmentType: '',
selectedCohort: null,
selectedTrack: null,
selectedAssignmentType: '',
selectedAssignment: '',
showSpinner: false,
tracks: [],
bulkImportError: '',
uploadSuccess: false,
showBulkManagement: false,
bulkManagementHistory: [],
gradeOverrideHistoryError: '',
showSpinner: false,
totalUsersCount: null,
filteredUsersCount: null,
tracks: [],
};
Gradebook.propTypes = {
areGradesFrozen: PropTypes.bool,
assignmentTypes: PropTypes.arrayOf(PropTypes.string),
assignmentFilterOptions: PropTypes.arrayOf(PropTypes.shape({
label: PropTypes.string,
subsectionLabel: PropTypes.string,
})),
canUserViewGradebook: PropTypes.bool,
closeBanner: PropTypes.func.isRequired,
cohorts: PropTypes.arrayOf(PropTypes.shape({
name: PropTypes.string,
id: PropTypes.number,
})),
filterAssignmentType: PropTypes.func.isRequired,
updateAssignmentFilter: PropTypes.func.isRequired,
updateAssignmentLimits: PropTypes.func.isRequired,
courseId: PropTypes.string,
fetchGradeOverrideHistory: PropTypes.func.isRequired,
filteredUsersCount: PropTypes.number,
format: PropTypes.string.isRequired,
getRoles: PropTypes.func.isRequired,
getUserGrades: PropTypes.func.isRequired,
fetchGradeOverrideHistory: PropTypes.func.isRequired,
gradeExportUrl: PropTypes.string.isRequired,
grades: PropTypes.arrayOf(PropTypes.shape({
percent: PropTypes.number,
section_breakdown: PropTypes.arrayOf(PropTypes.shape({
@@ -1010,61 +513,27 @@ Gradebook.propTypes = {
user_id: PropTypes.number,
user_name: PropTypes.string,
})),
gradeOverrides: PropTypes.arrayOf(PropTypes.shape({
date: PropTypes.string,
grader: PropTypes.string,
reason: PropTypes.string,
adjustedGrade: PropTypes.number,
})),
gradeOverrideCurrentEarnedGradedOverride: PropTypes.number,
gradeOriginalEarnedGraded: PropTypes.number,
gradeOriginalPossibleGraded: PropTypes.number,
doneViewingAssignment: PropTypes.func.isRequired,
headings: PropTypes.arrayOf(PropTypes.string).isRequired,
history: PropTypes.shape({
push: PropTypes.func,
}).isRequired,
initializeFilters: PropTypes.func.isRequired,
interventionExportUrl: PropTypes.string.isRequired,
location: PropTypes.shape({
search: PropTypes.string,
}),
courseId: PropTypes.string,
resetFilters: PropTypes.func.isRequired,
searchForUser: PropTypes.func.isRequired,
selectedAssignmentType: PropTypes.string,
selectedAssignment: PropTypes.string,
selectedCohort: PropTypes.string,
selectedTrack: PropTypes.string,
resetFilters: PropTypes.func.isRequired,
showBulkManagement: PropTypes.bool,
showSpinner: PropTypes.bool,
showSuccess: PropTypes.bool.isRequired,
toggleFormat: PropTypes.func.isRequired,
totalUsersCount: PropTypes.number,
tracks: PropTypes.arrayOf(PropTypes.shape({
name: PropTypes.string,
})),
closeBanner: PropTypes.func.isRequired,
updateGrades: PropTypes.func.isRequired,
gradeExportUrl: PropTypes.string.isRequired,
interventionExportUrl: PropTypes.string.isRequired,
submitFileUploadFormData: PropTypes.func.isRequired,
bulkImportError: PropTypes.string,
uploadSuccess: PropTypes.bool,
gradeOverrideHistoryError: PropTypes.string,
showBulkManagement: PropTypes.bool,
bulkManagementHistory: PropTypes.arrayOf(PropTypes.shape({
originalFilename: PropTypes.string.isRequired,
user: PropTypes.string.isRequired,
timeUploaded: PropTypes.string.isRequired,
summaryOfRowsProcessed: PropTypes.shape({
total: PropTypes.number.isRequired,
successfullyProcessed: PropTypes.number.isRequired,
failed: PropTypes.number.isRequired,
skipped: PropTypes.number.isRequired,
}).isRequired,
})),
totalUsersCount: PropTypes.number,
filteredUsersCount: PropTypes.number,
initializeFilters: PropTypes.func.isRequired,
updateGradesIfAssignmentGradeFiltersSet: PropTypes.func.isRequired,
updateCourseGradeFilter: PropTypes.func.isRequired,
downloadBulkGradesReport: PropTypes.func.isRequired,
downloadInterventionReport: PropTypes.func.isRequired,
};

View File

@@ -3,7 +3,6 @@ import { connect } from 'react-redux';
import Gradebook from '../../components/Gradebook';
import {
closeBanner,
doneViewingAssignment,
fetchGradeOverrideHistory,
fetchGrades,
fetchMatchingUserGrades,
@@ -11,8 +10,6 @@ import {
filterAssignmentType,
submitFileUploadFormData,
toggleGradeFormat,
updateGrades,
updateGradesIfAssignmentGradeFiltersSet,
downloadBulkGradesReport,
downloadInterventionReport,
} from '../../data/actions/grades';
@@ -47,33 +44,19 @@ function shouldShowSpinner(state) {
const mapStateToProps = (state, ownProps) => (
{
courseId: ownProps.match.params.courseId,
grades: state.grades.results,
gradeOverrides: state.grades.gradeOverrideHistoryResults,
gradeOverrideCurrentEarnedAllOverride: state.grades.gradeOverrideCurrentEarnedAllOverride,
gradeOverrideCurrentPossibleAllOverride: state.grades.gradeOverrideCurrentPossibleAllOverride,
gradeOverrideCurrentEarnedGradedOverride: state.grades.gradeOverrideCurrentEarnedGradedOverride,
gradeOverrideCurrentPossibleGradedOverride:
state.grades.gradeOverrideCurrentPossibleGradedOverride,
gradeOriginalEarnedGraded: state.grades.gradeOriginalEarnedGraded,
gradeOriginalPossibleGraded: state.grades.gradeOriginalPossibleGraded,
headings: getHeadings(state),
tracks: state.tracks.results,
cohorts: state.cohorts.results,
selectedTrack: state.filters.track,
selectedCohort: state.filters.cohort,
selectedAssignmentType: state.filters.assignmentType,
selectedAssignment: (state.filters.assignment || {}).label,
format: state.grades.gradeFormat,
showSuccess: state.grades.showSuccess,
gradeOverrideHistoryError: state.grades.overrideHistoryError,
prevPage: state.grades.prevPage,
nextPage: state.grades.nextPage,
areGradesFrozen: state.assignmentTypes.areGradesFrozen,
assignmentTypes: state.assignmentTypes.results,
assignmentFilterOptions: selectableAssignmentLabels(state),
areGradesFrozen: state.assignmentTypes.areGradesFrozen,
showSpinner: shouldShowSpinner(state),
bulkImportError: state.grades.bulkManagement
&& state.grades.bulkManagement.errorMessages
? `Errors while processing: ${state.grades.bulkManagement.errorMessages.join(', ')}`
: '',
bulkManagementHistory: getBulkManagementHistory(state),
cohorts: state.cohorts.results,
courseId: ownProps.match.params.courseId,
canUserViewGradebook: state.roles.canUserViewGradebook,
filteredUsersCount: state.grades.filteredUsersCount,
format: state.grades.gradeFormat,
gradeExportUrl: LmsApiService.getGradeExportCsvUrl(ownProps.match.params.courseId, {
cohort: getCohortNameById(state, state.filters.cohort),
track: state.filters.track,
@@ -90,6 +73,8 @@ const mapStateToProps = (state, ownProps) => (
courseGradeMin: formatMinCourseGrade(state.filters.courseGradeMin),
courseGradeMax: formatMaxCourseGrade(state.filters.courseGradeMax),
}),
grades: state.grades.results,
headings: getHeadings(state),
interventionExportUrl:
LmsApiService.getInterventionExportCsvUrl(ownProps.match.params.courseId, {
cohort: getCohortNameById(state, state.filters.cohort),
@@ -106,42 +91,42 @@ const mapStateToProps = (state, ownProps) => (
courseGradeMin: formatMinCourseGrade(state.filters.courseGradeMin),
courseGradeMax: formatMaxCourseGrade(state.filters.courseGradeMax),
}),
bulkImportError: state.grades.bulkManagement
&& state.grades.bulkManagement.errorMessages
? `Errors while processing: ${state.grades.bulkManagement.errorMessages.join(', ')}`
: '',
nextPage: state.grades.nextPage,
prevPage: state.grades.prevPage,
selectedTrack: state.filters.track,
selectedCohort: state.filters.cohort,
selectedAssignmentType: state.filters.assignmentType,
selectedAssignment: (state.filters.assignment || {}).label,
showBulkManagement: stateHasMastersTrack(state) && state.config.bulkManagementAvailable,
showSpinner: shouldShowSpinner(state),
showSuccess: state.grades.showSuccess,
totalUsersCount: state.grades.totalUsersCount,
tracks: state.tracks.results,
uploadSuccess: !!(state.grades.bulkManagement
&& state.grades.bulkManagement.uploadSuccess),
showBulkManagement: stateHasMastersTrack(state) && state.config.bulkManagementAvailable,
bulkManagementHistory: getBulkManagementHistory(state),
totalUsersCount: state.grades.totalUsersCount,
filteredUsersCount: state.grades.filteredUsersCount,
}
);
const mapDispatchToProps = {
doneViewingAssignment,
getUserGrades: fetchGrades,
fetchGradeOverrideHistory,
searchForUser: fetchMatchingUserGrades,
getPrevNextGrades: fetchPrevNextGrades,
getCohorts: fetchCohorts,
getTracks: fetchTracks,
getAssignmentTypes: fetchAssignmentTypes,
updateGrades,
toggleFormat: toggleGradeFormat,
filterAssignmentType,
closeBanner,
getRoles,
submitFileUploadFormData,
initializeFilters,
resetFilters,
updateAssignmentFilter,
updateAssignmentLimits,
updateGradesIfAssignmentGradeFiltersSet,
updateCourseGradeFilter,
downloadBulkGradesReport,
downloadInterventionReport,
fetchGradeOverrideHistory,
filterAssignmentType,
getAssignmentTypes: fetchAssignmentTypes,
getCohorts: fetchCohorts,
getPrevNextGrades: fetchPrevNextGrades,
getRoles,
getTracks: fetchTracks,
getUserGrades: fetchGrades,
initializeFilters,
resetFilters,
searchForUser: fetchMatchingUserGrades,
submitFileUploadFormData,
toggleFormat: toggleGradeFormat,
updateAssignmentFilter,
updateAssignmentLimits,
updateCourseGradeFilter,
};
const GradebookPage = connect(