diff --git a/.env b/.env index b266d9a..ec26f91 100644 --- a/.env +++ b/.env @@ -1,4 +1,5 @@ NODE_ENV='production', +NODE_PATH=./src BASE_URL=null, LMS_BASE_URL=null, LOGIN_URL=null, diff --git a/.gitignore b/.gitignore index 3f1af3a..0926f27 100755 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,5 @@ dist/ ### Emacs ### *~ +*.swo +*.swp diff --git a/package-lock.json b/package-lock.json index 56e074d..8126047 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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" diff --git a/src/components/Gradebook/Assignments.jsx b/src/components/Gradebook/Assignments.jsx new file mode 100644 index 0000000..c1df432 --- /dev/null +++ b/src/components/Gradebook/Assignments.jsx @@ -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 ( + +
+
+ + Assignment Types: + + +
+
+ + Assignment: + + +
+

Grade Range (0% - 100%)

+
+ + % + + % + + +
+
+ ); + } +} + +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); diff --git a/src/components/Gradebook/BulkManagement.jsx b/src/components/Gradebook/BulkManagement.jsx new file mode 100644 index 0000000..76af794 --- /dev/null +++ b/src/components/Gradebook/BulkManagement.jsx @@ -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 = ( + + + {resultsText} + + ); + const createWrappedCell = (text) => ({text}); + 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 ( + +

Use this feature by downloading a CSV for bulk management, + overriding grades locally, and coming back here to upload. +

+
+ + + + + +

+ Results appear in the table below.
+ Grade processing may take a few seconds. +

+ + + ); + } +} + +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); diff --git a/src/components/Gradebook/BulkManagementControls.jsx b/src/components/Gradebook/BulkManagementControls.jsx new file mode 100644 index 0000000..3c800f8 --- /dev/null +++ b/src/components/Gradebook/BulkManagementControls.jsx @@ -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 ( +
+ , + pending: , + }} + disabledStates={['pending']} + /> + , + pending: , + }} + disabledStates={['pending']} + /> +
+ ); + } +} + +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); diff --git a/src/components/Gradebook/EditModal.jsx b/src/components/Gradebook/EditModal.jsx new file mode 100644 index 0000000..9b80480 --- /dev/null +++ b/src/components/Gradebook/EditModal.jsx @@ -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 ( + +
+
Assignment:
+
{this.props.assignmentName}
+
Student:
+
{this.props.updateUserName}
+
Original Grade:
+
{this.props.gradeOriginalEarnedGraded}
+
Current Grade:
+
{this.props.gradeOverrideCurrentEarnedGradedOverride}
+
+ + {!this.props.gradeOverrideHistoryError && ( +
), + adjustedGrade: ( + + + {(this.props.adjustedGradePossible || this.props.gradeOriginalPossibleGraded) && ' / '} + {this.props.adjustedGradePossible || this.props.gradeOriginalPossibleGraded} + ), + }]} + /> + )} + +
Showing most recent actions (max 5). To see more, please contact + support. +
+
Note: Once you save, your changes will be visible to students.
+ + )} + buttons={[ + , + ]} + 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); diff --git a/src/components/Gradebook/GradebookTable.jsx b/src/components/Gradebook/GradebookTable.jsx new file mode 100644 index 0000000..9c047c7 --- /dev/null +++ b/src/components/Gradebook/GradebookTable.jsx @@ -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 => ( +
+
{entry.username}
+ {entry.external_user_key &&
{entry.external_user_key}
} +
+ ) + + roundGrade = percent => parseFloat((percent || 0).toFixed(DECIMAL_PRECISION)); + + formatter = { + percent: (entries, areGradesFrozen) => entries.map((entry) => { + const learnerInformation = this.getLearnerInformation(entry); + const results = { + Username: ( +
{learnerInformation}
+ ), + Email: ( + {entry.email} + ), + }; + + const assignments = entry.section_breakdown + .reduce((acc, subsection) => { + if (areGradesFrozen) { + acc[subsection.label] = `${this.roundGrade(subsection.percent * 100)} %`; + } else { + acc[subsection.label] = ( + + ); + } + 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: ( +
{learnerInformation}
+ ), + Email: ( + {entry.email} + ), + }; + + 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] = ( + + ); + } + 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 = ( +
+
Username
+
Student Key*
+
+ ); + 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 ( +
+
+
+ + + ); + } +} + +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); diff --git a/src/components/Gradebook/index.jsx b/src/components/Gradebook/index.jsx index 7feaed6..8f44741 100644 --- a/src/components/Gradebook/index.jsx +++ b/src/components/Gradebook/index.jsx @@ -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 => ( -
-
{entry.username}
- {entry.external_user_key &&
{entry.external_user_key}
} -
- ) - 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 = ( - - - {resultsText} - - ); - const filename = ( - - {originalFilename} - - ); - const user = ( - - {username} - - ); - 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: ( -
{learnerInformation}
- ), - Email: ( - {entry.email} - ), - }; - - const assignments = entry.section_breakdown - .reduce((acc, subsection) => { - if (areGradesFrozen) { - acc[subsection.label] = `${this.roundGrade(subsection.percent * 100)} %`; - } else { - acc[subsection.label] = ( - - ); - } - 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: ( -
{learnerInformation}
- ), - Email: ( - {entry.email} - ), - }; - - 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] = ( - - ); - } - 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 = ( -
-
Username
-
Student Key*
-
- ); - 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 ( {this.props.showBulkManagement && ( -
- , - pending: , - }} - disabledStates={['pending']} - /> - , - pending: , - }} - disabledStates={['pending']} - /> -
+ )} -
-
-
- - + {PageButtons(this.props)}

* available for learners in the Master's track only

- -
-
Assignment:
{this.state.modalAssignmentName}
-
Student:
{this.state.updateUserName}
-
Original Grade:
{this.props.gradeOriginalEarnedGraded}
-
Current Grade:
{this.props.gradeOverrideCurrentEarnedGradedOverride}
-
- - {!this.props.gradeOverrideHistoryError && ( -
this.onChange(value)} - ref={(input) => { this.overrideReasonInput = input; }} - />), - adjustedGrade: ( - - this.onChange(value)} - /> - {(this.state.adjustedGradePossible - || this.props.gradeOriginalPossibleGraded) - && ' / '} - {this.state.adjustedGradePossible - || this.props.gradeOriginalPossibleGraded} - ), - }]} - /> - )} - -
Showing most recent actions (max 5). To see more, please contact - support. -
-
Note: Once you save, your changes will be visible to students.
- - )} - buttons={[ - , - ]} - 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} /> + - {this.props.showBulkManagement && ( - -

Use this feature by downloading a CSV for bulk management, - overriding grades locally, and coming back here to upload. -

-
- - - - - -

- Results appear in the table below.
- Grade processing may take a few seconds. -

-
- - )} + {this.props.showBulkManagement && + + } )} @@ -824,73 +401,14 @@ export default class Gradebook extends React.Component { )} > - -
-
- - Assignment Types: - - -
-
- - Assignment: - - -
-

Grade Range (0% - 100%)

-
- - % - - % - - -
-
+
( { - 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(