diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a119c3b7d..ce3017c2a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,90 @@ All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. +### [10.0.1-36](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.1-35...v10.0.1-36) (2026-05-05) + +### [10.0.1-35](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.1-34...v10.0.1-35) (2026-04-28) + + +### Features + +* bulk import staff via emails ([#1195](https://github.com/b0ink/doubtfire-deploy/issues/1195)) ([c91ab47](https://github.com/b0ink/doubtfire-deploy/commit/c91ab478ae1d1cd459040290f6f7a44c7dce3efe)) +* edit comments ([#1194](https://github.com/b0ink/doubtfire-deploy/issues/1194)) ([976b6ac](https://github.com/b0ink/doubtfire-deploy/commit/976b6ac4fa3bd2d5e954eebcae3fcb40dd8d1f0e)) + + +### Bug Fixes + +* task route transition race when switching from inbox ([63c52dc](https://github.com/b0ink/doubtfire-deploy/commit/63c52dc4cdb2f2fbc701f69c64b7273f5778c868)) + +### [10.0.1-34](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.1-33...v10.0.1-34) (2026-04-27) + +### [10.0.1-33](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.1-32...v10.0.1-33) (2026-04-23) + + +### Features + +* allow paste attachment comment ([#1165](https://github.com/b0ink/doubtfire-deploy/issues/1165)) ([0f24980](https://github.com/b0ink/doubtfire-deploy/commit/0f24980e6edcc5d0f81e015990adaf14afea400e)) + + +### Bug Fixes + +* open report in turnitin ([091aaf8](https://github.com/b0ink/doubtfire-deploy/commit/091aaf8ba744bbf677f9b8f51a58bc16b7d631ab)) + +### [10.0.1-32](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.1-31...v10.0.1-32) (2026-04-18) + +### [10.0.1-31](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.1-30...v10.0.1-31) (2026-04-18) + +### [10.0.1-30](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.1-29...v10.0.1-30) (2026-04-18) + + +### Features + +* batch upload feedback csv ([#1175](https://github.com/b0ink/doubtfire-deploy/issues/1175)) ([9f95987](https://github.com/b0ink/doubtfire-deploy/commit/9f959877b6f47d878d87b468f4aab73f536d598b)) +* display icon for tasks escalated by student ([dfcfd47](https://github.com/b0ink/doubtfire-deploy/commit/dfcfd472305ef2971fa72734c18e77253b98fa11)) +* enable task pinning in explorer ([1647e2b](https://github.com/b0ink/doubtfire-deploy/commit/1647e2bcc86ac6fb08c82be9795a06939c57bb6e)) + + +### Bug Fixes + +* debounce duplicate task submission requests ([80f92e2](https://github.com/b0ink/doubtfire-deploy/commit/80f92e2277c48978e1738340063a7223c04fddb8)) + +### [10.0.1-29](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.1-28...v10.0.1-29) (2026-04-15) + + +### Features + +* discussed in class refactor ([#1145](https://github.com/b0ink/doubtfire-deploy/issues/1145)) ([4d8bb5b](https://github.com/b0ink/doubtfire-deploy/commit/4d8bb5b82d89caa02ed4936819be601b3f6977fc)) + +### [10.0.1-28](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.1-27...v10.0.1-28) (2026-04-13) + +### [10.0.1-27](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.27...v10.0.1-27) (2026-04-13) + +### [10.0.27](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.1-26...v10.0.27) (2026-04-13) + + +### Features + +* confirmation modal to reassign tutorials when removing staff ([5f90e90](https://github.com/b0ink/doubtfire-deploy/commit/5f90e9085d69913eced55ad54ce9ac09431d3822)) + +### [10.0.1-26](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.1-25...v10.0.1-26) (2026-03-26) + +### [10.0.1-25](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.1-24...v10.0.1-25) (2026-03-26) + + +### Features + +* display sso redirecting state ([248c992](https://github.com/b0ink/doubtfire-deploy/commit/248c992f46488f61916e00a87ca32ed987721f31)) +* pause feedback threshold during teaching period breaks ([#1138](https://github.com/b0ink/doubtfire-deploy/issues/1138)) ([12fbf81](https://github.com/b0ink/doubtfire-deploy/commit/12fbf8147107dc185a9a9cbea940efac93a0f316)) + +### [10.0.1-24](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.1-23...v10.0.1-24) (2026-03-24) + + +### Features + +* confirm recursive fix in mobile tutor view ([b64601a](https://github.com/b0ink/doubtfire-deploy/commit/b64601a425c7a64bccfaf47c1e4fccb0343b1589)) + +### [10.0.1-23](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.1-22...v10.0.1-23) (2026-03-24) + ### [10.0.1-22](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.1-21...v10.0.1-22) (2026-03-23) diff --git a/package-lock.json b/package-lock.json index 19291ceb8e..756a0fb315 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "doubtfire", - "version": "10.0.1-22", + "version": "10.0.1-36", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "doubtfire", - "version": "10.0.1-22", + "version": "10.0.1-36", "license": "AGPL-3.0", "dependencies": { "@angular/animations": "^17.3.6", @@ -58,9 +58,9 @@ "font-awesome": "~4.7.0", "html2canvas": "^1.4.1", "html5-qrcode": "^2.3.8", - "jquery": "2.1.4", + "jquery": "4.0.0", "jszip": "^3.10.1", - "lodash": "~4.17", + "lodash": "~4.18", "lottie-web": "^5.13.0", "marked": "^11.1.0", "moment": "^2.29.4", @@ -77,7 +77,7 @@ "qrcode": "^1.5.4", "rxjs": "~7.8.2", "ts-md5": "^1.3.1", - "tslib": "^2.6.2", + "tslib": "^2.8.1", "underscore.string": "2.3.3", "zone.js": "~0.14" }, @@ -141,9 +141,9 @@ "karma-jasmine-html-reporter": "^1.5.0", "load-grunt-tasks": "^5.0.0", "npm-run-all2": "^7.0", - "postcss": "^8.4.27", + "postcss": "^8.5.10", "postcss-scss": "^0.1.7", - "prettier": "^3.1.0", + "prettier": "^3.8.3", "protractor": "~7.0.0", "sass": "^1.48.0", "tailwindcss": "~3.3", @@ -427,6 +427,13 @@ "node": ">=14.0.0" } }, + "node_modules/@angular-devkit/build-angular/node_modules/tslib": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", + "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==", + "dev": true, + "license": "0BSD" + }, "node_modules/@angular-devkit/build-webpack": { "version": "0.1703.7", "dev": true, @@ -3413,7 +3420,9 @@ "license": "Python-2.0" }, "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { - "version": "1.1.11", + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", "dev": true, "license": "MIT", "dependencies": { @@ -3495,7 +3504,9 @@ } }, "node_modules/@humanwhocodes/config-array/node_modules/brace-expansion": { - "version": "1.1.11", + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", "dev": true, "license": "MIT", "dependencies": { @@ -5198,9 +5209,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.0.tgz", - "integrity": "sha512-DX2x7CMcrJzsE91q7/O02IJQ5/aLkVtYFryqCjduJhUfGKG6yJV8hxaw8pZa93lLEpPTP/ohdN4wFz7yp/ry9A==", + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.2.tgz", + "integrity": "sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg==", "cpu": [ "arm64" ], @@ -6612,6 +6623,9 @@ }, "node_modules/angular-sanitize": { "version": "1.5.11", + "resolved": "https://registry.npmjs.org/angular-sanitize/-/angular-sanitize-1.5.11.tgz", + "integrity": "sha512-9yVOr8YOefo0/4q+ImqNdGcbfGzelQIoHW0OoaoU/U5wpRZNn5IqlkdLW9udieSiprYzuXeqiS1V7ZiHurYisw==", + "deprecated": "For the actively supported Angular, see https://www.npmjs.com/package/@angular/core. AngularJS support has officially ended. For extended AngularJS support options, see https://goo.gle/angularjs-path-forward.", "license": "MIT" }, "node_modules/angular-ui-bootstrap": { @@ -7133,15 +7147,15 @@ "license": "MIT" }, "node_modules/axios": { - "version": "1.13.6", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz", - "integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==", + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.0.tgz", + "integrity": "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==", "dev": true, "license": "MIT", "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", - "proxy-from-env": "^1.1.0" + "proxy-from-env": "^2.1.0" } }, "node_modules/axobject-query": { @@ -7580,7 +7594,9 @@ "license": "MIT" }, "node_modules/brace-expansion": { - "version": "2.0.1", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", + "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" @@ -10081,7 +10097,9 @@ } }, "node_modules/eslint-plugin-import/node_modules/brace-expansion": { - "version": "1.1.11", + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", "dev": true, "license": "MIT", "dependencies": { @@ -10253,7 +10271,9 @@ "license": "Python-2.0" }, "node_modules/eslint/node_modules/brace-expansion": { - "version": "1.1.11", + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", "dev": true, "license": "MIT", "dependencies": { @@ -11029,9 +11049,9 @@ "license": "ISC" }, "node_modules/follow-redirects": { - "version": "1.15.11", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", - "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", "dev": true, "funding": [ { @@ -11200,7 +11220,6 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, "hasInstallScript": true, "optional": true, "os": [ @@ -11446,7 +11465,9 @@ "license": "BSD-2-Clause" }, "node_modules/glob/node_modules/brace-expansion": { - "version": "1.1.11", + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", "devOptional": true, "license": "MIT", "dependencies": { @@ -11591,7 +11612,9 @@ } }, "node_modules/globule/node_modules/brace-expansion": { - "version": "1.1.11", + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", "dev": true, "license": "MIT", "dependencies": { @@ -12523,6 +12546,13 @@ "node": ">=8" } }, + "node_modules/grunt-legacy-log-utils/node_modules/lodash": { + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "dev": true, + "license": "MIT" + }, "node_modules/grunt-legacy-log-utils/node_modules/supports-color": { "version": "7.2.0", "dev": true, @@ -12534,6 +12564,13 @@ "node": ">=8" } }, + "node_modules/grunt-legacy-log/node_modules/lodash": { + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "dev": true, + "license": "MIT" + }, "node_modules/grunt-legacy-util": { "version": "2.0.1", "dev": true, @@ -12556,6 +12593,13 @@ "dev": true, "license": "MIT" }, + "node_modules/grunt-legacy-util/node_modules/lodash": { + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "dev": true, + "license": "MIT" + }, "node_modules/grunt-legacy-util/node_modules/sprintf-js": { "version": "1.1.3", "dev": true, @@ -12761,7 +12805,9 @@ } }, "node_modules/grunt/node_modules/brace-expansion": { - "version": "1.1.11", + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", "dev": true, "license": "MIT", "dependencies": { @@ -14232,7 +14278,9 @@ "license": "MIT" }, "node_modules/jake/node_modules/brace-expansion": { - "version": "1.1.11", + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", "dev": true, "license": "MIT", "dependencies": { @@ -14478,7 +14526,10 @@ } }, "node_modules/jquery": { - "version": "2.1.4" + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jquery/-/jquery-4.0.0.tgz", + "integrity": "sha512-TXCHVR3Lb6TZdtw1l3RTLf8RBWVGexdxL6AC8/e0xZKEpBflBsjh9/8LXw+dkNFuOyW9B7iB3O1sP7hS0Kiacg==", + "license": "MIT" }, "node_modules/js-base64": { "version": "2.6.4", @@ -14550,7 +14601,9 @@ } }, "node_modules/jshint/node_modules/brace-expansion": { - "version": "1.1.11", + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", "dev": true, "license": "MIT", "dependencies": { @@ -14628,6 +14681,13 @@ "dev": true, "license": "MIT" }, + "node_modules/jshint/node_modules/lodash": { + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "dev": true, + "license": "MIT" + }, "node_modules/jshint/node_modules/minimatch": { "version": "3.0.8", "dev": true, @@ -14867,7 +14927,9 @@ } }, "node_modules/karma-coverage-istanbul-reporter/node_modules/brace-expansion": { - "version": "1.1.11", + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", "dev": true, "license": "MIT", "dependencies": { @@ -14938,7 +15000,9 @@ } }, "node_modules/karma/node_modules/brace-expansion": { - "version": "1.1.11", + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", "dev": true, "license": "MIT", "dependencies": { @@ -15332,7 +15396,9 @@ } }, "node_modules/lodash": { - "version": "4.17.21", + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", "license": "MIT" }, "node_modules/lodash.camelcase": { @@ -16062,7 +16128,9 @@ } }, "node_modules/multimatch/node_modules/brace-expansion": { - "version": "1.1.11", + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", "dev": true, "license": "MIT", "dependencies": { @@ -16105,7 +16173,9 @@ "optional": true }, "node_modules/nanoid": { - "version": "3.3.7", + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", "dev": true, "funding": [ { @@ -16396,9 +16466,9 @@ } }, "node_modules/node-forge": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.3.tgz", - "integrity": "sha512-rLvcdSyRCyouf6jcOIPe/BgwG/d7hKjzMKOas33/pHEr6gbq18IK9zV7DiPvzsz0oBJPme6qr6H6kGZuI9/DZg==", + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.4.0.tgz", + "integrity": "sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==", "dev": true, "license": "(BSD-3-Clause OR GPL-2.0)", "engines": { @@ -18036,7 +18106,9 @@ } }, "node_modules/postcss": { - "version": "8.4.38", + "version": "8.5.10", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz", + "integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==", "dev": true, "funding": [ { @@ -18054,9 +18126,9 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.7", - "picocolors": "^1.0.0", - "source-map-js": "^1.2.0" + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" }, "engines": { "node": "^10 || ^12 || >=14" @@ -18405,7 +18477,9 @@ } }, "node_modules/prettier": { - "version": "3.2.5", + "version": "3.8.3", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz", + "integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==", "dev": true, "license": "MIT", "bin": { @@ -18728,9 +18802,14 @@ } }, "node_modules/proxy-from-env": { - "version": "1.1.0", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=10" + } }, "node_modules/prr": { "version": "1.0.1", @@ -19771,6 +19850,20 @@ "fsevents": "~2.3.2" } }, + "node_modules/rollup/node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.0.tgz", + "integrity": "sha512-DX2x7CMcrJzsE91q7/O02IJQ5/aLkVtYFryqCjduJhUfGKG6yJV8hxaw8pZa93lLEpPTP/ohdN4wFz7yp/ry9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, "node_modules/run-async": { "version": "3.0.0", "license": "MIT", @@ -20769,7 +20862,9 @@ } }, "node_modules/source-map-js": { - "version": "1.2.0", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", "dev": true, "license": "BSD-3-Clause", "engines": { @@ -21543,7 +21638,9 @@ } }, "node_modules/test-exclude/node_modules/brace-expansion": { - "version": "1.1.11", + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", "dev": true, "license": "MIT", "dependencies": { @@ -21893,7 +21990,9 @@ } }, "node_modules/tslib": { - "version": "2.6.2", + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, "node_modules/tuf-js": { diff --git a/package.json b/package.json index 6a353dbcaa..80e455b7b2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "doubtfire", - "version": "10.0.1-22", + "version": "10.0.1-36", "homepage": "http://github.com/doubtfire-lms/", "description": "Learning and feedback tool.", "license": "AGPL-3.0", @@ -77,9 +77,9 @@ "font-awesome": "~4.7.0", "html2canvas": "^1.4.1", "html5-qrcode": "^2.3.8", - "jquery": "2.1.4", + "jquery": "4.0.0", "jszip": "^3.10.1", - "lodash": "~4.17", + "lodash": "~4.18", "lottie-web": "^5.13.0", "marked": "^11.1.0", "moment": "^2.29.4", @@ -96,7 +96,7 @@ "qrcode": "^1.5.4", "rxjs": "~7.8.2", "ts-md5": "^1.3.1", - "tslib": "^2.6.2", + "tslib": "^2.8.1", "underscore.string": "2.3.3", "zone.js": "~0.14" }, @@ -160,9 +160,9 @@ "karma-jasmine-html-reporter": "^1.5.0", "load-grunt-tasks": "^5.0.0", "npm-run-all2": "^7.0", - "postcss": "^8.4.27", + "postcss": "^8.5.10", "postcss-scss": "^0.1.7", - "prettier": "^3.1.0", + "prettier": "^3.8.3", "protractor": "~7.0.0", "sass": "^1.48.0", "tailwindcss": "~3.3", diff --git a/src/app/api/models/task-comment/task-comment.ts b/src/app/api/models/task-comment/task-comment.ts index 5d49c9ef9f..fad8869f9a 100644 --- a/src/app/api/models/task-comment/task-comment.ts +++ b/src/app/api/models/task-comment/task-comment.ts @@ -7,6 +7,8 @@ import {DoubtfireConstants} from 'src/app/config/constants/doubtfire-constants'; import {AlertService} from 'src/app/common/services/alert.service'; export class TaskComment extends Entity { + private static readonly EDIT_WINDOW_MS = 10 * 60 * 1000; + // Linked objects task: Task; originalComment: TaskComment = null; @@ -57,11 +59,38 @@ export class TaskComment extends Entity { return ['text', 'discussion', 'audio', 'image', 'pdf'].includes(this.commentType); } + public get isStaffAuthored(): boolean { + return ( + this.task?.unit?.staff?.some((unitRole) => unitRole.user.id === this.author?.id) ?? false + ); + } + + public get isAutomated(): boolean { + if (!this.isBubbleComment) { + return true; + } + + return this.text?.trim().startsWith('**Automated Message:**') ?? false; + } + + public get isManualFeedback(): boolean { + return this.isStaffAuthored && !this.isAutomated; + } + public get project(): Project { return this.task.project; } public get currentUserCanEdit() { + return ( + this.authorIsMe && + this.commentType === 'text' && + this.createdAt instanceof Date && + new Date().getTime() - this.createdAt.getTime() <= TaskComment.EDIT_WINDOW_MS + ); + } + + public get currentUserCanDelete() { return this.authorIsMe || this.project?.unit.currentUserIsStaff; } diff --git a/src/app/api/models/task-status.ts b/src/app/api/models/task-status.ts index 0933627924..eabf670adb 100644 --- a/src/app/api/models/task-status.ts +++ b/src/app/api/models/task-status.ts @@ -19,6 +19,7 @@ export type TaskStatusEnum = export type TaskStatusUiData = { status: TaskStatusEnum; icon: string; + materialIcon: string; label: string; class: string; help: {detail: string; reason: string; action: string}; @@ -237,6 +238,24 @@ export class TaskStatus { ['attention_required', 'fa fa-commenting'], ]); + // Material icons used by newer UI elements. + public static readonly STATUS_MATERIAL_ICONS = new Map([ + ['ready_for_feedback', 'thumb_up'], + ['not_started', 'pause'], + ['working_on_it', 'bolt'], + ['need_help', 'help'], + ['redo', 'undo'], + ['feedback_exceeded', 'visibility_off'], + ['fix_and_resubmit', 'construction'], + ['discuss', 'question_answer'], + ['demonstrate', 'record_voice_over'], + ['complete', 'done'], + ['fail', 'close'], + ['time_exceeded', 'schedule'], + ['assess_in_portfolio', 'folder_open'], + ['attention_required', 'sms_failed'], + ]); + // Please make sure this matches task-status-colors.less public static readonly STATUS_COLORS = new Map([ ['ready_for_feedback', '#0079D8'], @@ -439,6 +458,7 @@ export class TaskStatus { return { status: status, icon: TaskStatus.STATUS_ICONS.get(status), + materialIcon: TaskStatus.STATUS_MATERIAL_ICONS.get(status), label: TaskStatus.STATUS_LABELS.get(status), class: TaskStatus.statusClass(status), help: TaskStatus.HELP_DESCRIPTIONS.get(status), diff --git a/src/app/api/models/task.ts b/src/app/api/models/task.ts index 2d7d995792..2f65d2bcce 100644 --- a/src/app/api/models/task.ts +++ b/src/app/api/models/task.ts @@ -20,14 +20,17 @@ import { ScormComment, UnitRoleService, UnitRole, + UserService, } from './doubtfire-model'; +import {TutorNoteService} from '../services/tutor-note.service'; import {Grade} from './grade'; import {LOCALE_ID} from '@angular/core'; import {HttpClient} from '@angular/common/http'; -import {Observable, map} from 'rxjs'; +import {Observable, firstValueFrom, map} from 'rxjs'; import {gradeTaskModal, uploadSubmissionModal} from 'src/app/ajs-upgraded-providers'; import {AlertService} from 'src/app/common/services/alert.service'; import {MappingFunctions} from '../services/mapping-fn'; +import {TaskPrerequisite} from './task-prerequisite'; export const FeedbackModerationAction = { ShowMore: 'show_more', @@ -125,6 +128,10 @@ export class Task extends Entity { return !this.requiresDiscussionForComplete || this.hasDiscussedInClassComment; } + public get latestReadyForFeedbackAt(): Date | null { + return this.submissionDate ? new Date(this.submissionDate) : null; + } + public get tutor(): UnitRole { const enrolments = this.project.tutorialEnrolmentsCache.currentValues.filter( (t) => t.tutorialStream.name === this.definition.tutorialStream.name, @@ -146,11 +153,66 @@ export class Task extends Entity { }); } + private commentsSinceLatestReadyForFeedback(): readonly TaskComment[] { + const latestReadyForFeedbackAt = this.latestReadyForFeedbackAt?.getTime(); + if (!latestReadyForFeedbackAt) { + return this.comments; + } + + return this.comments.filter((comment) => { + const createdAt = comment.createdAt ? new Date(comment.createdAt).getTime() : NaN; + return Number.isFinite(createdAt) && createdAt >= latestReadyForFeedbackAt; + }); + } + public get unit(): Unit { if (this._unit) return this._unit; return this.project.unit; } + private getBreakOverlapMilliseconds( + startTime: number, + endTime: number, + breaks: readonly {startDate: Date; numberOfWeeks: number}[], + ): number { + const millisecondsPerDay = 1000 * 60 * 60 * 24; + + return breaks.reduce((overlap, teachingBreak) => { + const breakStart = new Date(teachingBreak.startDate).getTime(); + const breakDuration = (teachingBreak.numberOfWeeks ?? 0) * 7 * millisecondsPerDay; + const breakEnd = breakStart + breakDuration; + + if (!Number.isFinite(breakStart) || breakDuration <= 0) { + return overlap; + } + + const overlapStart = Math.max(startTime, breakStart); + const overlapEnd = Math.min(endTime, breakEnd); + + return overlap + Math.max(0, overlapEnd - overlapStart); + }, 0); + } + + public daysSinceSubmission(nowTime = Date.now()): number { + const millisecondsPerDay = 1000 * 60 * 60 * 24; + const submissionTime = new Date(this.submissionDate).getTime(); + + if (!Number.isFinite(submissionTime) || nowTime <= submissionTime) { + return 0; + } + + const teachingBreaks = this.unit.teachingPeriod?.breaks ?? []; + const pausedMilliseconds = this.getBreakOverlapMilliseconds( + submissionTime, + nowTime, + teachingBreaks, + ); + + return Math.floor( + Math.max(0, nowTime - submissionTime - pausedMilliseconds) / millisecondsPerDay, + ); + } + /** * Determine if a task matches a given search text. * @@ -681,6 +743,58 @@ export class Task extends Entity { ); } + private mapUnitTaskPrerequisites(prerequisites: TaskPrerequisite[]): TaskPrerequisite[] { + const definitions = this.unit.taskDefinitions; + + return prerequisites.map((prerequisite) => { + prerequisite.taskDefinition = definitions.find( + (td) => td.id === prerequisite.taskDefinitionId, + ); + prerequisite.prerequisite = definitions.find((td) => td.id === prerequisite.prerequisiteId); + return prerequisite; + }); + } + + private buildProjectTaskForDefinition(definition: TaskDefinition): Task { + const dependentTask = new Task(this.project); + dependentTask.project = this.project; + dependentTask.definition = definition; + return dependentTask; + } + + private async dependentTaskNeedsRecursiveFix(definition: TaskDefinition): Promise { + const cachedTask = this.project.findTaskForDefinition(definition.id); + if (cachedTask) { + return cachedTask.status === 'ready_for_feedback'; + } + + const dependentTask = this.buildProjectTaskForDefinition(definition); + const taskWithSubmissionDetails = await firstValueFrom(dependentTask.getSubmissionDetails()); + return taskWithSubmissionDetails.status === 'ready_for_feedback'; + } + + public async hasReadyForFeedbackDependents(): Promise { + const allPrerequisites = await firstValueFrom(this.unit.getTaskPrerequisites()); + const dependentPrerequisites = this.mapUnitTaskPrerequisites(allPrerequisites).filter( + (prerequisite) => prerequisite.prerequisiteId === this.definition.id, + ); + + for (const prerequisite of dependentPrerequisites) { + if (!prerequisite.taskDefinition) { + continue; + } + + const shouldTriggerRecursiveFix = await this.dependentTaskNeedsRecursiveFix( + prerequisite.taskDefinition, + ); + if (shouldTriggerRecursiveFix) { + return true; + } + } + + return false; + } + public get overseerEnabled(): boolean { return this.unit.overseerEnabled && this.definition.assessmentEnabled; } @@ -795,38 +909,78 @@ export class Task extends Entity { } } - public markAsDiscussed() { + public async markAsDiscussed(reasonText?: string) { const alerts: AlertService = AppInjector.get(AlertService); const taskService: TaskService = AppInjector.get(TaskService); - const options: RequestOptions = { - entity: this, - cache: this.project.taskCache, - body: { - discussed: true, - }, + const markDiscussed = () => { + const options: RequestOptions = { + entity: this, + cache: this.project.taskCache, + body: { + discussed: true, + }, + }; + + taskService + .update( + { + projectId: this.project.id, + taskDefId: this.definition.id, + }, + options, + ) + .subscribe({ + next: (_response) => { + taskService.notifyStatusChange(this); + alerts.success('Task successfully marked as discussed in class.', 4000); + }, + error: (error) => { + alerts.error(error, 6000); + }, + }); }; - taskService - .update( - { - projectId: this.project.id, - taskDefId: this.definition.id, - }, - options, - ) - .subscribe({ - next: (_response) => { - taskService.notifyStatusChange(this); - alerts.success('Task successfully marked as discussed in class.', 4000); - }, - error: (error) => { - alerts.error(error, 6000); - }, - }); + if (reasonText) { + const prefix = `I'm manually marking this discussed in class because...`; + const trimmedReason = reasonText.trim(); + const noteText = trimmedReason.startsWith(prefix) + ? trimmedReason + : `${prefix} ${trimmedReason}`; + const currentUser = AppInjector.get(UserService).currentUser; + const currentUnitRole = this.unit.staff.find( + (unitRole) => unitRole.user.id === currentUser.id, + ); + + if (!currentUnitRole) { + alerts.error( + 'Unable to find your staff role, so the tutor note could not be recorded.', + 6000, + ); + return; + } + + AppInjector.get(TutorNoteService) + .addNote(currentUnitRole, noteText, this) + .subscribe({ + next: () => { + markDiscussed(); + }, + error: (error) => { + alerts.error(`Unable to save the required tutor note: ${error}`, 6000); + }, + }); + return; + } + + markDiscussed(); } - public updateTaskStatus(status: TaskStatusEnum, markAsDiscussed?: boolean) { + public async updateTaskStatus( + status: TaskStatusEnum, + markAsDiscussed?: boolean, + triggerRecursiveFix?: boolean, + ) { const oldStatus = this.status; const alerts: AlertService = AppInjector.get(AlertService); @@ -851,6 +1005,10 @@ export class Task extends Entity { options.body['discussed'] = true; } + if (triggerRecursiveFix === true) { + options.body['trigger_recursive_fix'] = true; + } + const hasId: boolean = this.id > 0; taskService @@ -904,7 +1062,7 @@ export class Task extends Entity { } } - public triggerTransition(status: TaskStatusEnum): void { + public async triggerTransition(status: TaskStatusEnum): Promise { if (this.status === status) return; const alerts: AlertService = AppInjector.get(AlertService); @@ -917,7 +1075,7 @@ export class Task extends Entity { } else if (requiresFileUpload && !this.isReadyForUpload) { alerts.error('Complete Knowledge Check first to submit files', 6000); } else { - this.updateTaskStatus(status); + await this.updateTaskStatus(status); } } @@ -939,12 +1097,13 @@ export class Task extends Entity { return this.project.getGroupForTask(this); } - public pin(): void { + public pin(onSuccess?: () => void): void { const http = AppInjector.get(HttpClient); http.post(`${AppInjector.get(DoubtfireConstants).API_URL}/tasks/${this.id}/pin`, {}).subscribe({ next: (data) => { this.pinned = true; + onSuccess?.(); }, error: (message) => { (AppInjector.get(AlertService) as AlertService).error(message, 6000); @@ -952,7 +1111,7 @@ export class Task extends Entity { }); } - public unpin(): void { + public unpin(onSuccess?: () => void): void { const http = AppInjector.get(HttpClient); http @@ -960,6 +1119,7 @@ export class Task extends Entity { .subscribe({ next: (_data) => { this.pinned = false; + onSuccess?.(); }, error: (message) => { (AppInjector.get(AlertService) as AlertService).error(message, 6000); diff --git a/src/app/api/models/unit.ts b/src/app/api/models/unit.ts index e9f48f02f9..e30181ddab 100644 --- a/src/app/api/models/unit.ts +++ b/src/app/api/models/unit.ts @@ -75,6 +75,7 @@ export class Unit extends Entity { extensionWeeksOnResubmitRequest: number; allowStudentChangeTutorial: boolean; markLateSubmissionsAsAssessInPortfolio: boolean; + enforceFeedbackBeforeDiscussedInClass: boolean; feedbackWarningThresholdDays: number; feedbackOverflowThresholdDays: number; @@ -585,6 +586,16 @@ export class Unit extends Entity { }`; } + public getBatchFeedbackUploadUrl(taskDefinition: TaskDefinition | number): string { + const params = new URLSearchParams({unit_id: `${this.id}`}); + const taskDefinitionId = + taskDefinition instanceof TaskDefinition ? taskDefinition.id : taskDefinition; + + params.set('task_definition_id', `${taskDefinitionId}`); + + return `${AppInjector.get(DoubtfireConstants).API_URL}/submission/batch_feedback_csv.json?${params.toString()}`; + } + public getTaskDefinitionBatchUploadUrl(): string { return `${AppInjector.get(DoubtfireConstants).API_URL}/csv/task_definitions?unit_id=${this.id}`; } diff --git a/src/app/api/services/task-comment.service.ts b/src/app/api/services/task-comment.service.ts index 6761bc715c..a85489cfe5 100644 --- a/src/app/api/services/task-comment.service.ts +++ b/src/app/api/services/task-comment.service.ts @@ -228,6 +228,33 @@ export class TaskCommentService extends CachedEntityService { ); } + public editComment(comment: TaskComment, text: string): Observable { + const opts: RequestOptions = { + endpointFormat: this.commentEndpointFormat, + entity: comment, + body: { + comment: text, + }, + cache: comment.task.commentCache, + constructorParams: comment.task, + }; + + return super + .update( + { + id: comment.id, + projectId: comment.project.id, + taskDefinitionId: comment.task.definition.id, + }, + opts, + ) + .pipe( + tap((_updatedComment: TaskComment) => { + comment.task.refreshCommentData(); + }), + ); + } + public requestExtension( reason: string, weeksRequested: number, diff --git a/src/app/api/services/task-definition.service.ts b/src/app/api/services/task-definition.service.ts index d420f2c1fc..364d0f89c3 100644 --- a/src/app/api/services/task-definition.service.ts +++ b/src/app/api/services/task-definition.service.ts @@ -146,6 +146,7 @@ export class TaskDefinitionService extends CachedEntityService { { keys: 'overseerSteps', toEntityOp: (data: object, key: string, taskDefinition: TaskDefinition) => { + taskDefinition.overseerStepsCache.clear(); data[key]?.forEach((overseerStep) => { taskDefinition.overseerStepsCache.getOrCreate( overseerStep['id'], diff --git a/src/app/api/services/task.service.ts b/src/app/api/services/task.service.ts index 8aaa3805a2..fdc69de09d 100644 --- a/src/app/api/services/task.service.ts +++ b/src/app/api/services/task.service.ts @@ -188,6 +188,9 @@ export class TaskService extends CachedEntityService { constructorParams: unit, }, ).pipe( + map((tasks: Task[]) => + tasks.filter((t) => t.daysSinceSubmission() >= t.unit.feedbackOverflowThresholdDays), + ), tap((tasks: Task[]) => { unit.incorporateTasks(tasks); }), @@ -228,6 +231,7 @@ export class TaskService extends CachedEntityService { public readonly statusSeq = TaskStatus.STATUS_SEQ; public readonly helpDescriptions = TaskStatus.HELP_DESCRIPTIONS; public readonly statusIcons: Map = TaskStatus.STATUS_ICONS; + public readonly statusMaterialIcons: Map = TaskStatus.STATUS_MATERIAL_ICONS; public readonly rejectFutureStates = TaskStatus.REJECT_FUTURE_STATES; public statusClass(status: TaskStatusEnum): string { diff --git a/src/app/api/services/unit.service.ts b/src/app/api/services/unit.service.ts index 1d6462727d..cb9986c33f 100644 --- a/src/app/api/services/unit.service.ts +++ b/src/app/api/services/unit.service.ts @@ -257,6 +257,7 @@ export class UnitService extends CachedEntityService { // 'groupMemberships', - map to group memberships 'feedbackWarningThresholdDays', 'feedbackOverflowThresholdDays', + 'enforceFeedbackBeforeDiscussedInClass', ); this.mapping.addJsonKey( @@ -288,6 +289,7 @@ export class UnitService extends CachedEntityService { 'allowStudentChangeTutorial', 'feedbackWarningThresholdDays', 'feedbackOverflowThresholdDays', + 'enforceFeedbackBeforeDiscussedInClass', ); } diff --git a/src/app/common/footer/footer.component.html b/src/app/common/footer/footer.component.html index cb6e238c38..50233659f3 100644 --- a/src/app/common/footer/footer.component.html +++ b/src/app/common/footer/footer.component.html @@ -12,7 +12,7 @@ } - +
- +
@@ -58,7 +59,7 @@ }}" matTooltipPosition="above" aria-label="" - class="button extra-large-button status-chip" + class="button extra-large-button status-chip suggested-task-status" (click)="selectedTask?.updateTaskStatus(selectedTask.suggestedTaskStatus)" [ngClass]="taskService.statusData(selectedTask.suggestedTaskStatus).class" [disabled]=" @@ -66,7 +67,9 @@ (selectedTask?.suggestedTaskStatus === 'complete' && !selectedTask?.canMarkComplete) " > - + + {{ taskService.statusData(selectedTask.suggestedTaskStatus).materialIcon }} + } @@ -76,7 +79,8 @@ matTooltip="Mark as Discuss" matTooltipPosition="above" aria-label="" - class="button large-button" + class="button status-chip discuss" + [class.large-button]="!(selectedTask && selectedTask.suggestedTaskStatus)" (click)="selectedTask?.updateTaskStatus('discuss')" > question_answer @@ -89,7 +93,7 @@ matTooltip="Mark as Working On It" matTooltipPosition="above" aria-label="Mark as Working On It" - class="button large-button" + class="button large-button status-chip working-on-it" (click)="markTaskWorkingOnIt(selectedTask)" > bolt @@ -108,7 +112,7 @@ matTooltip="Mark as Complete" matTooltipPosition="above" aria-label="Mark as Complete" - class="button" + class="button status-chip complete" (click)="selectedTask?.updateTaskStatus('complete')" > done @@ -290,7 +294,7 @@ - @@ -306,11 +310,15 @@ Return to Not Started } + - + diff --git a/src/app/common/modals/confirmation-modal/confirmation-modal.component.ts b/src/app/common/modals/confirmation-modal/confirmation-modal.component.ts index f9506e5b8b..d0ef5a3691 100644 --- a/src/app/common/modals/confirmation-modal/confirmation-modal.component.ts +++ b/src/app/common/modals/confirmation-modal/confirmation-modal.component.ts @@ -5,7 +5,10 @@ import {MAT_DIALOG_DATA, MatDialogRef} from '@angular/material/dialog'; export interface ConfirmationModalData { title: string; message: string; - action?: any; + action?: () => void; + cancelAction?: () => void; + confirmText?: string; + cancelText?: string; } @Component({ @@ -17,6 +20,9 @@ export class ConfirmationModalComponent implements OnInit { @Input() title: string; @Input() message: string; @Input() action: () => void; + @Input() cancelActionFn: () => void; + @Input() confirmText: string; + @Input() cancelText: string; constructor( @Inject(AlertService) private alertService: AlertService, @@ -29,6 +35,9 @@ export class ConfirmationModalComponent implements OnInit { this.title = this.data.title; this.message = this.data.message; this.action = this.data.action; + this.cancelActionFn = this.data.cancelAction; + this.confirmText = this.data.confirmText ?? 'Confirm'; + this.cancelText = this.data.cancelText ?? 'Cancel'; } public confirmAction() { @@ -41,7 +50,11 @@ export class ConfirmationModalComponent implements OnInit { } public cancelAction() { - this.alertService.success(`${this.title} action cancelled.`); + if (typeof this.cancelActionFn === 'function') { + this.cancelActionFn(); + } else { + this.alertService.success(`${this.title} action cancelled.`); + } this.dialogRef.close(); } } diff --git a/src/app/common/modals/confirmation-modal/confirmation-modal.service.ts b/src/app/common/modals/confirmation-modal/confirmation-modal.service.ts index 660ddec60c..b14d252ebb 100644 --- a/src/app/common/modals/confirmation-modal/confirmation-modal.service.ts +++ b/src/app/common/modals/confirmation-modal/confirmation-modal.service.ts @@ -11,7 +11,10 @@ export class ConfirmationModalService { public show( title: string, message: string, - action?: any, + action?: () => void, + cancelAction?: () => void, + confirmText?: string, + cancelText?: string, ): MatDialogRef { return this.dialog.open( ConfirmationModalComponent, @@ -20,6 +23,9 @@ export class ConfirmationModalService { title, message, action, + cancelAction, + confirmText, + cancelText, }, position: {top: '2.5%'}, width: '100%', diff --git a/src/app/common/modals/discussed-in-class-reason-modal/discussed-in-class-reason-modal.component.html b/src/app/common/modals/discussed-in-class-reason-modal/discussed-in-class-reason-modal.component.html new file mode 100644 index 0000000000..91f1913f62 --- /dev/null +++ b/src/app/common/modals/discussed-in-class-reason-modal/discussed-in-class-reason-modal.component.html @@ -0,0 +1,33 @@ +

{{ data.title }}

+ + +

{{ data.prompt }}

+ +
+ {{ data.prefix }} +
+ + + Reason + + @if (reasonBody.length > 0 && !hasReasonBody) { + Please enter at least {{ minimumReasonLength }} characters. + } +
+ {{ trimmedReasonBody.length }}/1000 +
+
+
+ + + + + diff --git a/src/app/common/modals/discussed-in-class-reason-modal/discussed-in-class-reason-modal.component.scss b/src/app/common/modals/discussed-in-class-reason-modal/discussed-in-class-reason-modal.component.scss new file mode 100644 index 0000000000..5bf3e5c253 --- /dev/null +++ b/src/app/common/modals/discussed-in-class-reason-modal/discussed-in-class-reason-modal.component.scss @@ -0,0 +1,7 @@ +.mat-mdc-dialog-content { + min-width: min(640px, 80vw); +} + +.prefix-preview { + white-space: pre-wrap; +} diff --git a/src/app/common/modals/discussed-in-class-reason-modal/discussed-in-class-reason-modal.component.ts b/src/app/common/modals/discussed-in-class-reason-modal/discussed-in-class-reason-modal.component.ts new file mode 100644 index 0000000000..098337f5f4 --- /dev/null +++ b/src/app/common/modals/discussed-in-class-reason-modal/discussed-in-class-reason-modal.component.ts @@ -0,0 +1,47 @@ +import {Component, Inject} from '@angular/core'; +import {MAT_DIALOG_DATA, MatDialogRef} from '@angular/material/dialog'; + +export interface DiscussedInClassReasonModalData { + title: string; + prompt: string; + prefix: string; +} + +@Component({ + selector: 'f-discussed-in-class-reason-modal', + templateUrl: './discussed-in-class-reason-modal.component.html', + styleUrl: './discussed-in-class-reason-modal.component.scss', +}) +export class DiscussedInClassReasonModalComponent { + public readonly minimumReasonLength = 25; + public reasonBody = ''; + + constructor( + public dialogRef: MatDialogRef, + @Inject(MAT_DIALOG_DATA) public data: DiscussedInClassReasonModalData, + ) {} + + public get trimmedReasonBody(): string { + return this.reasonBody.trim(); + } + + public get hasReasonBody(): boolean { + return this.trimmedReasonBody.length >= this.minimumReasonLength; + } + + public get notePreview(): string { + return `${this.data.prefix} ${this.trimmedReasonBody}`.trim(); + } + + public cancel(): void { + this.dialogRef.close(undefined); + } + + public submit(): void { + if (!this.hasReasonBody) { + return; + } + + this.dialogRef.close(this.notePreview); + } +} diff --git a/src/app/common/modals/discussed-in-class-reason-modal/discussed-in-class-reason-modal.service.ts b/src/app/common/modals/discussed-in-class-reason-modal/discussed-in-class-reason-modal.service.ts new file mode 100644 index 0000000000..f67abc3e8a --- /dev/null +++ b/src/app/common/modals/discussed-in-class-reason-modal/discussed-in-class-reason-modal.service.ts @@ -0,0 +1,35 @@ +import {Injectable} from '@angular/core'; +import {MatDialog, MatDialogRef} from '@angular/material/dialog'; +import { + DiscussedInClassReasonModalComponent, + DiscussedInClassReasonModalData, +} from './discussed-in-class-reason-modal.component'; + +@Injectable({ + providedIn: 'root', +}) +export class DiscussedInClassReasonModalService { + constructor(private dialog: MatDialog) {} + + public show( + title: string, + prompt: string, + prefix: string, + ): MatDialogRef { + return this.dialog.open< + DiscussedInClassReasonModalComponent, + DiscussedInClassReasonModalData, + string | undefined + >(DiscussedInClassReasonModalComponent, { + data: { + title, + prompt, + prefix, + }, + position: {top: '2.5%'}, + width: '100%', + maxWidth: '700px', + autoFocus: false, + }); + } +} diff --git a/src/app/common/user-badge/user-badge.component.html b/src/app/common/user-badge/user-badge.component.html index 548f6e3485..20cfa08553 100644 --- a/src/app/common/user-badge/user-badge.component.html +++ b/src/app/common/user-badge/user-badge.component.html @@ -1,5 +1,9 @@ diff --git a/src/app/common/user-badge/user-badge.component.ts b/src/app/common/user-badge/user-badge.component.ts index ed501ffbc7..a07521fa2e 100644 --- a/src/app/common/user-badge/user-badge.component.ts +++ b/src/app/common/user-badge/user-badge.component.ts @@ -1,6 +1,6 @@ -import { Component, Input } from '@angular/core'; -import { UIRouter } from '@uirouter/angular'; -import { Task } from 'src/app/api/models/doubtfire-model'; +import {Component, Input} from '@angular/core'; +import {UIRouter} from '@uirouter/angular'; +import {Task} from 'src/app/api/models/doubtfire-model'; @Component({ selector: 'f-user-badge', @@ -19,6 +19,30 @@ export class UserBadgeComponent { return this.selectedTask == null; } + get studentRouteParams(): {projectId: number; tutor: boolean; taskAbbr: string} | undefined { + if (this.unselected) { + return undefined; + } + + return { + projectId: this.selectedTask.project.id, + tutor: true, + taskAbbr: '', + }; + } + + get studentTaskRouteParams(): {projectId: number; tutor: boolean; taskAbbr: string} | undefined { + if (this.unselected) { + return undefined; + } + + return { + projectId: this.selectedTask.project.id, + taskAbbr: this.selectedTask.definition.abbreviation, + tutor: true, + }; + } + goToStudent(): void { this.router.stateService.go('projects/dashboard', { projectId: this.selectedTask.project.id, diff --git a/src/app/doubtfire-angular.module.ts b/src/app/doubtfire-angular.module.ts index ae030bcc3e..ca169d6c61 100644 --- a/src/app/doubtfire-angular.module.ts +++ b/src/app/doubtfire-angular.module.ts @@ -1,6 +1,10 @@ import {interval} from 'rxjs'; import {take} from 'rxjs/operators'; - +import {FormsModule, ReactiveFormsModule} from '@angular/forms'; +import {MatAutocompleteModule} from '@angular/material/autocomplete'; +import {MatIconModule, MatIconRegistry} from '@angular/material/icon'; +import {MatPaginatorModule} from '@angular/material/paginator'; +import {MatButtonModule} from '@angular/material/button'; import {NgModule, Injector, DoBootstrap} from '@angular/core'; import {BrowserModule, DomSanitizer, Title} from '@angular/platform-browser'; import {UpgradeModule} from '@angular/upgrade/static'; @@ -10,20 +14,18 @@ import {BrowserAnimationsModule} from '@angular/platform-browser/animations'; // Lottie animation module // import {LottieModule, LottieCacheModule} from 'ngx-lottie'; +import {FStudentsListComponent} from './units/states/students-list/students-list.component'; import {provideLottieOptions, LottieComponent} from 'ngx-lottie'; import player from 'lottie-web'; import {ClipboardModule} from '@angular/cdk/clipboard'; import {DragDropModule} from '@angular/cdk/drag-drop'; import {MatToolbarModule} from '@angular/material/toolbar'; import {MatSelectModule} from '@angular/material/select'; -import {MatButtonModule} from '@angular/material/button'; import {MatMenuModule} from '@angular/material/menu'; import {MatCheckboxModule} from '@angular/material/checkbox'; import {MatDialogModule} from '@angular/material/dialog'; import {MatDividerModule} from '@angular/material/divider'; import {MatFormFieldModule} from '@angular/material/form-field'; -import {MatAutocompleteModule} from '@angular/material/autocomplete'; -import {MatIconModule, MatIconRegistry} from '@angular/material/icon'; import {MatInputModule} from '@angular/material/input'; import {MatBadgeModule} from '@angular/material/badge'; import {MatListModule} from '@angular/material/list'; @@ -33,7 +35,6 @@ import {MatSliderModule} from '@angular/material/slider'; import {MatExpansionModule} from '@angular/material/expansion'; import {MatStepperModule} from '@angular/material/stepper'; import {MatSnackBarModule} from '@angular/material/snack-bar'; -import {MatPaginatorModule} from '@angular/material/paginator'; import {MatTooltipModule} from '@angular/material/tooltip'; import {MatSlideToggleModule} from '@angular/material/slide-toggle'; import {MatChipListbox, MatChipsModule} from '@angular/material/chips'; @@ -85,8 +86,8 @@ import { TaskCommentComposerComponent, DiscussionComposerDialog, } from 'src/app/tasks/task-comment-composer/task-comment-composer.component'; -import {FormsModule, ReactiveFormsModule} from '@angular/forms'; +import {AttachmentConfirmationDialogComponent} from 'src/app/tasks/task-comment-composer/attachment-confirmation-dialog/attachment-confirmation-dialog.component'; import {AudioCommentRecorderComponent} from './common/audio-recorder/audio/audio-comment-recorder/audio-comment-recorder'; import {DiscussionPromptComposerComponent} from './tasks/task-comment-composer/discussion-prompt-composer/discussion-prompt-composer.component'; import {MatProgressSpinnerModule} from '@angular/material/progress-spinner'; @@ -103,6 +104,7 @@ import {ExtensionModalComponent} from './common/modals/extension-modal/extension import {CalendarModalComponent} from './common/modals/calendar-modal/calendar-modal.component'; import {CommentsModalComponent} from './common/modals/comments-modal/comments-modal.component'; import {ConfirmationModalComponent} from './common/modals/confirmation-modal/confirmation-modal.component'; +import {DiscussedInClassReasonModalComponent} from './common/modals/discussed-in-class-reason-modal/discussed-in-class-reason-modal.component'; import {MatRadioModule} from '@angular/material/radio'; import {MatButtonToggleModule} from '@angular/material/button-toggle'; import { @@ -148,6 +150,7 @@ import {fPdfViewerComponent} from './common/pdf-viewer/pdf-viewer.component'; import {SafePipe} from './common/pipes/safe.pipe'; import {PdfViewerPanelComponent} from './common/pdf-viewer-panel/pdf-viewer-panel.component'; import {StaffTaskListComponent} from './units/states/tasks/inbox/directives/staff-task-list/staff-task-list.component'; +import {BatchFeedbackWorkflowDialogComponent} from './units/states/tasks/inbox/directives/staff-task-list/batch-feedback-workflow-dialog/batch-feedback-workflow-dialog.component'; import {FiltersPipe} from './common/filters/filters.pipe'; import {TasksOfTaskDefinitionPipe} from './common/filters/tasks-of-task-definition.pipe'; import {TasksInTutorialsPipe} from './common/filters/tasks-in-tutorials.pipe'; @@ -168,10 +171,10 @@ import { TutorialService, TutorialStreamService, UnitService, + UserService, TaskService, ProjectService, UnitRoleService, - UserService, WebcalService, LearningOutcomeService, TaskSimilarityService, @@ -323,6 +326,7 @@ import {TutorNotesModalComponent} from './common/modals/tutor-notes-modal/tutor- import {FeedbackAppealModalComponent} from './tasks/modals/feedback-appeal-modal/feedback-appeal-modal.component'; import {ConfirmModerationModalComponent} from './units/states/tasks/inbox/directives/moderation/confirm-moderation-modal/confirm-moderation-modal.component'; import {TaskClaimComponent} from './units/states/tasks/inbox/directives/task-claim/task-claim.component'; +import {BulkImportStaffModalComponent} from './units/states/edit/directives/unit-staff-editor/bulk-import-staff-modal/bulk-import-staff-modal.component'; // See https://stackoverflow.com/questions/55721254/how-to-change-mat-datepicker-date-format-to-dd-mm-yyyy-in-simplest-way/58189036#58189036 const MY_DATE_FORMAT = { @@ -365,12 +369,14 @@ const GANTT_CHART_CONFIG = { @NgModule({ // Components we declare declarations: [ + FStudentsListComponent, AlertComponent, AboutDoubtfireModalContent, D2lUnitDetailsFormComponent, D2lTransferComponent, TeachingPeriodUnitImportDialogComponent, TaskCommentComposerComponent, + AttachmentConfirmationDialogComponent, AudioCommentRecorderComponent, MicrophoneTesterComponent, DiscussionPromptComposerComponent, @@ -388,6 +394,7 @@ const GANTT_CHART_CONFIG = { SpecConModalComponent, CalendarModalComponent, ConfirmationModalComponent, + DiscussedInClassReasonModalComponent, InstitutionSettingsComponent, ProjectPlanComponent, SuccessCloseComponent, @@ -425,6 +432,7 @@ const GANTT_CHART_CONFIG = { SafePipe, PdfViewerPanelComponent, StaffTaskListComponent, + BatchFeedbackWorkflowDialogComponent, TaskSimilarityViewComponent, FiltersPipe, TasksOfTaskDefinitionPipe, @@ -498,6 +506,7 @@ const GANTT_CHART_CONFIG = { TaskDefinitionPrerequisitesComponent, TaskPrerequisitesCardComponent, UnitStaffEditorComponent, + BulkImportStaffModalComponent, GroupSetSelectorComponent, UnitDetailsEditorComponent, PortfolioGradeSelectStepComponent, diff --git a/src/app/doubtfire-angularjs.module.ts b/src/app/doubtfire-angularjs.module.ts index ca57426fd2..0129878427 100644 --- a/src/app/doubtfire-angularjs.module.ts +++ b/src/app/doubtfire-angularjs.module.ts @@ -108,7 +108,7 @@ import 'build/src/app/units/states/rollover/directives/unit-dates-selector/unit- import 'build/src/app/units/states/rollover/directives/directives.js'; import 'build/src/app/units/states/rollover/rollover.js'; import 'build/src/app/units/states/index/index.js'; -import 'build/src/app/units/states/students-list/students-list.js'; +import {FStudentsListComponent} from './units/states/students-list/students-list.component'; import 'build/src/app/units/states/analytics/analytics.js'; import 'build/src/app/common/filters/filters.js'; import 'build/src/app/common/content-editable/content-editable.js'; @@ -126,8 +126,8 @@ import 'build/src/app/common/services/date-service.js'; import 'build/src/app/sessions/auth/http-auth-injector.js'; import 'build/src/app/sessions/sessions.js'; import 'build/src/app/errors/errors.js'; -import 'build/src/app/errors/states/unauthorised/unauthorised.js'; import 'build/src/app/errors/states/timeout/timeout.js'; +import 'build/src/app/errors/states/unauthorised/unauthorised.js'; import 'build/src/app/errors/states/states.js'; import 'build/src/common/utilService/utilService.js'; import 'build/src/common/i18n/localize.js'; @@ -485,6 +485,14 @@ DoubtfireAngularJSModule.directive( ); DoubtfireAngularJSModule.directive('fUnits', downgradeComponent({component: FUnitsComponent})); +DoubtfireAngularJSModule.directive( + 'fStudentsList', + downgradeComponent({ + component: FStudentsListComponent, + inputs: ['unit', 'tutor'], + }), +); + // Global configuration DoubtfireAngularJSModule.directive( 'taskCommentsViewer', diff --git a/src/app/doubtfire.states.ts b/src/app/doubtfire.states.ts index f1710f6b54..59ff3946f1 100644 --- a/src/app/doubtfire.states.ts +++ b/src/app/doubtfire.states.ts @@ -15,6 +15,25 @@ import {ProjectPlanComponent} from './projects/states/plan/project-plan.componen import {JplagReportViewerComponent} from './projects/states/jplag/jplag-report-viewer.component'; import {LtiDashboardComponent} from './home/states/lti-dashboard/lti-dashboard.component'; import {LtiUnitLinkComponent} from './home/states/lti-unit-link/lti-unit-link.component'; +import {FStudentsListComponent} from './units/states/students-list/students-list.component'; + +type LegacyTransition = { + params: () => {unitId: string | number}; + injector: () => { + get: (serviceName: string) => { + get?: (id: number) => { + subscribe: (handlers: { + next: (unit: unknown) => void; + error: (error: unknown) => void; + }) => void; + }; + loadStudents?: (unit: unknown) => { + subscribe: (handlers: {next: () => void; error: () => void}) => void; + }; + }; + }; +}; + /* * Use this file to store any states that are sourced by angular components. */ @@ -37,6 +56,44 @@ const institutionSettingsState: NgHybridStateDeclaration = { }, }; +const StudentsListState: NgHybridStateDeclaration = { + name: 'units/students/list', + parent: 'units/index', + url: '/students', + component: FStudentsListComponent, + resolve: [ + { + token: 'unit', + deps: ['$transition$'], + resolveFn: (transition: LegacyTransition) => { + const unitId = Number(transition.params().unitId); + const newUnitService = transition.injector().get('newUnitService'); + const newProjectService = transition.injector().get('newProjectService'); + + return new Promise((resolve, reject) => { + newUnitService.get(unitId).subscribe({ + next: (unit: unknown) => { + newProjectService.loadStudents(unit).subscribe({ + next: () => resolve(unit), + error: () => resolve(unit), + }); + }, + error: reject, + }); + }); + }, + }, + ], + bindings: { + unit: 'unit', + }, + data: { + task: 'Student List', + pageTitle: '_Home_', + roleWhitelist: ['Tutor', 'Convenor', 'Admin', 'Auditor'], + }, +}; + const usersState: NgHybridStateDeclaration = { name: 'admin/users', // This is the name of the state to jump to - so ui-sref="users" to jump here url: '/admin/users', // You get here with this url @@ -586,6 +643,7 @@ export const doubtfireStates = [ EditProfileState, EulaState, usersState, + StudentsListState, ViewAllProjectsState, ViewAllUnits, AdministerUnits, diff --git a/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-due-card/task-due-card.component.html b/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-due-card/task-due-card.component.html index 9b40ddb5d6..97c882ca8e 100644 --- a/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-due-card/task-due-card.component.html +++ b/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-due-card/task-due-card.component.html @@ -91,9 +91,7 @@

You should have completed this task by {{ task?.localDueDateString() }}. Make sure to discuss this task with your tutor as soon as possible. If you do not - discuss this task by {{ task?.localDeadlineDateString() }}, it will be marked as Time - Exceeded. + >. Make sure to discuss this task with your tutor as soon as possible.

Tasks are only considered completed once your tutor has @@ -103,8 +101,7 @@

You should have completed this task by {{ task?.localDueDateString() }}. Make sure to discuss this task with your tutor as soon as possible. If this task - remains on this state for an extended period, it will be marked as Time Exceeded. + >. Make sure to discuss this task with your tutor as soon as possible.

Tasks are only considered completed once your tutor has diff --git a/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-similarity-view/task-similarity-view.component.html b/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-similarity-view/task-similarity-view.component.html index a7f0e0aed0..9dcb038923 100644 --- a/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-similarity-view/task-similarity-view.component.html +++ b/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-similarity-view/task-similarity-view.component.html @@ -40,12 +40,12 @@

> summarize - } @else { + } @else if (similarity.type === 'TiiTaskSimilarity') { diff --git a/src/app/projects/states/jplag/jplag-report-viewer.component.ts b/src/app/projects/states/jplag/jplag-report-viewer.component.ts index 4021eb6d22..79d7f62248 100644 --- a/src/app/projects/states/jplag/jplag-report-viewer.component.ts +++ b/src/app/projects/states/jplag/jplag-report-viewer.component.ts @@ -65,7 +65,7 @@ export class JplagReportViewerComponent { getScroller(doc)?.scrollBy(0, 600); elapsed += 50; - if (elapsed >= 5000) { + if (elapsed >= 10000) { clearInterval(interval); this.alertService.error('Could not open JPlag comparison.', 6000); } diff --git a/src/app/projects/states/tutor-discussion/tutor-discussion.component.ts b/src/app/projects/states/tutor-discussion/tutor-discussion.component.ts index 43416b60c1..c9e43c890a 100644 --- a/src/app/projects/states/tutor-discussion/tutor-discussion.component.ts +++ b/src/app/projects/states/tutor-discussion/tutor-discussion.component.ts @@ -17,6 +17,8 @@ import { UnitService, UserService, } from 'src/app/api/models/doubtfire-model'; +import {ConfirmationModalService} from 'src/app/common/modals/confirmation-modal/confirmation-modal.service'; +import {DiscussedInClassReasonModalService} from 'src/app/common/modals/discussed-in-class-reason-modal/discussed-in-class-reason-modal.service'; import {AlertService} from 'src/app/common/services/alert.service'; import {GradeService} from 'src/app/common/services/grade.service'; @@ -32,6 +34,8 @@ enum TutorDiscussionTabView { encapsulation: ViewEncapsulation.None, // enables custom material-ui css }) export class TutorDiscussionComponent implements AfterViewInit { + private readonly discussedInClassNotePrefix = `I'm manually marking this discussed in class because...`; + @Input() unitId: number; @Input() username: string; @Input() attendance: boolean; @@ -66,6 +70,8 @@ export class TutorDiscussionComponent implements AfterViewInit { private gradeService: GradeService, private state: StateService, private alertService: AlertService, + private confirmationModalService: ConfirmationModalService, + private discussedInClassReasonModal: DiscussedInClassReasonModalService, private route: UIRouter, private taskCommentService: TaskCommentService, private taskService: TaskService, @@ -262,7 +268,7 @@ export class TutorDiscussionComponent implements AfterViewInit { this.selectedTask = task; } - public setSelectedTasksStatus(status: TaskStatusEnum) { + public async setSelectedTasksStatus(status: TaskStatusEnum) { const selectedTasks = this.tasksList.selectedOptions.selected.map((taskOption) => { return taskOption.value as Task; }); @@ -279,6 +285,44 @@ export class TutorDiscussionComponent implements AfterViewInit { } } + if (status === 'fix_and_resubmit') { + try { + const hasReadyDependents = ( + await Promise.all( + selectedTasks.map((task) => + task?.definition && task?.project ? task.hasReadyForFeedbackDependents() : false, + ), + ) + ).some(Boolean); + + if (hasReadyDependents) { + this.confirmationModalService.show( + 'Move dependent tasks to Fix and Resubmit?', + 'One or more selected tasks are prerequisites for other tasks submitted by this student that are Ready for Feedback. Do you want to move those tasks to Fix and Resubmit as well?', + () => { + this.updateSelectedTasksStatus(selectedTasks, status, true); + }, + () => { + this.updateSelectedTasksStatus(selectedTasks, status, false); + }, + 'Yes, update dependent tasks', + 'No, just selected tasks', + ); + return; + } + } catch (error) { + this.alertService.error(`Failed to check dependent task statuses: ${error}`, 6000); + } + } + + this.updateSelectedTasksStatus(selectedTasks, status, false); + } + + private updateSelectedTasksStatus( + selectedTasks: Task[], + status: TaskStatusEnum, + moveDependentTasks: boolean, + ) { for (const task of selectedTasks) { if ( status === 'complete' && @@ -290,6 +334,8 @@ export class TutorDiscussionComponent implements AfterViewInit { if (task.definition.assessInPortfolioOnly) { task.updateTaskStatus(status === 'complete' ? 'working_on_it' : status, true); + } else if (status === 'fix_and_resubmit') { + task.updateTaskStatus(status, true, moveDependentTasks); } else { task.updateTaskStatus(status, true); } @@ -310,10 +356,33 @@ export class TutorDiscussionComponent implements AfterViewInit { public markSelectedTasksDicussed() { const selectedTasks = this.tasksList.selectedOptions.selected; - for (const taskOption of selectedTasks) { - const task = taskOption.value as Task; - task.markAsDiscussed(); + if (!this.unit?.enforceFeedbackBeforeDiscussedInClass) { + for (const taskOption of selectedTasks) { + const task = taskOption.value as Task; + task.markAsDiscussed(); + } + return; } + + this.discussedInClassReasonModal + .show( + 'Mark Discussed in Class', + `Add a tutor note explaining why ${selectedTasks.length} task${ + selectedTasks.length === 1 ? '' : 's' + } ${selectedTasks.length === 1 ? 'is' : 'are'} being marked as discussed in class.`, + this.discussedInClassNotePrefix, + ) + .afterClosed() + .subscribe((reason) => { + if (!reason) { + return; + } + + for (const taskOption of selectedTasks) { + const task = taskOption.value as Task; + task.markAsDiscussed(reason); + } + }); } public markSelectedTasksCheckedIn() { @@ -397,10 +466,25 @@ export class TutorDiscussionComponent implements AfterViewInit { this.filteredTasks = [...this.allTasks]; } + private filteredDiscussionTasks(tasks: readonly Task[]): Task[] { + return tasks.filter((task) => { + if (!this.statusesToInclude.includes(task.status)) { + return false; + } + + if ( + this.unit?.enforceFeedbackBeforeDiscussedInClass && + task.status === 'ready_for_feedback' + ) { + return false; + } + + return true; + }); + } + public viewAllFilteredTasks() { - const discussionTasks = this.project?.tasks.filter((task) => - this.statusesToInclude.includes(task.status), - ); + const discussionTasks = this.filteredDiscussionTasks(this.project?.tasks ?? []); this.filteredTasks = [...discussionTasks]; } @@ -419,9 +503,7 @@ export class TutorDiscussionComponent implements AfterViewInit { return this.getProject(this.unit, student.id); }) .then((project) => { - const discussionTasks = project.tasks.filter((task) => - this.statusesToInclude.includes(task.status), - ); + const discussionTasks = this.filteredDiscussionTasks(project.tasks); if (!this.attendance) { this.filteredTasks = [...discussionTasks]; this.allTasks = [ diff --git a/src/app/sessions/states/sign-in/sign-in.component.html b/src/app/sessions/states/sign-in/sign-in.component.html index 74fd6b7b99..29b7743d41 100644 --- a/src/app/sessions/states/sign-in/sign-in.component.html +++ b/src/app/sessions/states/sign-in/sign-in.component.html @@ -57,7 +57,14 @@

type="form" [disabled]="form.invalid" > - Sign In +
+ @if (redirectingSSO) { + Signing In... + + } @else { + Sign In + } +
diff --git a/src/app/sessions/states/sign-in/sign-in.component.ts b/src/app/sessions/states/sign-in/sign-in.component.ts index 8f047489d4..2196ee83c9 100644 --- a/src/app/sessions/states/sign-in/sign-in.component.ts +++ b/src/app/sessions/states/sign-in/sign-in.component.ts @@ -54,6 +54,8 @@ export class SignInComponent implements OnInit { public isLoading: boolean = true; public authMethodFailed: boolean = false; + public redirectingSSO: boolean = false; + // Get query params from the resolve in the router state @Input() username: string; @Input() authToken: string; @@ -163,10 +165,13 @@ export class SignInComponent implements OnInit { }); } else if (this.SSOLoginUrl) { if (this.autoLogin) { + this.redirectingSSO = true; return wait.then(() => { // Double check in case changed in the meantime if (this.autoLogin) { this.redirectToSSO(); + } else { + this.redirectingSSO = false; } }); } else { diff --git a/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.coffee b/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.coffee index a310b31abf..82886d8e41 100644 --- a/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.coffee +++ b/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.coffee @@ -36,6 +36,7 @@ angular.module('doubtfire.tasks.modals.upload-submission-modal', []) $scope.privacyPolicy = PrivacyPolicy # Expose task to scope $scope.task = task + $scope.uploadSubmitLocked = false # Set up submission types submissionTypes = _.chain(newTaskService.submittableStatuses).map((status) -> @@ -86,8 +87,11 @@ angular.module('doubtfire.tasks.modals.upload-submission-modal', []) $modalInstance.close(task) alertService.error("Upload failed. Please try again, or contact your tutor if the issue continues.", 8000) - onFailureCancel: $modalInstance.dismiss + onFailureCancel: -> + $scope.uploadSubmitLocked = false + $modalInstance.dismiss() onComplete: -> + $scope.uploadSubmitLocked = false return unless $scope.uploader.response? and $scope.uploader.response.id? $modalInstance.close(task) # unless $scope.task.isTestSubmission @@ -179,7 +183,7 @@ angular.module('doubtfire.tasks.modals.upload-submission-modal', []) false submit: -> # Disable if no comment is supplied with need_help, or if submitting for feedback and task is assess in portfolio only - !$scope.uploader.isReady or ($scope.comment.trim().length < 25 && ((($scope.submissionType == 'ready_for_feedback' || $scope.submissionType == 'reupload_evidence') && $scope.task.definition.assessInPortfolioOnly) || $scope.submissionType == 'need_help') ) + $scope.uploadSubmitLocked or !$scope.uploader.isReady or ($scope.comment.trim().length < 25 && ((($scope.submissionType == 'ready_for_feedback' || $scope.submissionType == 'reupload_evidence') && $scope.task.definition.assessInPortfolioOnly) || $scope.submissionType == 'need_help') ) cancel: -> # Can't cancel whilst uploading $scope.uploader.isUploading @@ -203,6 +207,9 @@ angular.module('doubtfire.tasks.modals.upload-submission-modal', []) # Click upload on UI $scope.uploadButtonClicked = -> + return if $scope.uploadSubmitLocked || $scope.uploader.isUploading + + $scope.uploadSubmitLocked = true # Move files to the end to simulate as though state move states.shown = _.without(states.shown, 'files') states.shown.push('files') diff --git a/src/app/tasks/task-comment-composer/attachment-confirmation-dialog/attachment-confirmation-dialog.component.html b/src/app/tasks/task-comment-composer/attachment-confirmation-dialog/attachment-confirmation-dialog.component.html new file mode 100644 index 0000000000..9a67131959 --- /dev/null +++ b/src/app/tasks/task-comment-composer/attachment-confirmation-dialog/attachment-confirmation-dialog.component.html @@ -0,0 +1,55 @@ +

Post Attachment?

+ +

This attachment is ready to post as a task comment.

+ +
+ @if (isImage) { + + } @else if (isPdf) { +
+ picture_as_pdf +
+ {{ file.name }} + PDF document +
+
+ } @else if (isAudio) { +
+
+ audio_file +
+ {{ file.name }} + {{ file.type || 'Audio file' }} +
+
+ +
+ } @else { +
+ attach_file +
+ {{ file.name }} + {{ file.type || 'Attachment' }} +
+
+ } +
+ +

+ {{ file.name }} + @if (file.size) { + ({{ formatFileSize(file.size) }}) + } +

+
+ + + + + diff --git a/src/app/tasks/task-comment-composer/attachment-confirmation-dialog/attachment-confirmation-dialog.component.ts b/src/app/tasks/task-comment-composer/attachment-confirmation-dialog/attachment-confirmation-dialog.component.ts new file mode 100644 index 0000000000..00fcb1e5ea --- /dev/null +++ b/src/app/tasks/task-comment-composer/attachment-confirmation-dialog/attachment-confirmation-dialog.component.ts @@ -0,0 +1,59 @@ +import {Component, Inject, OnDestroy, OnInit} from '@angular/core'; +import {MAT_DIALOG_DATA, MatDialogRef} from '@angular/material/dialog'; + +export interface AttachmentConfirmationDialogData { + file: File; +} + +@Component({ + selector: 'f-attachment-confirmation-dialog', + templateUrl: './attachment-confirmation-dialog.component.html', +}) +export class AttachmentConfirmationDialogComponent implements OnInit, OnDestroy { + public file: File; + public previewUrl: string | null = null; + + constructor( + public dialogRef: MatDialogRef, + @Inject(MAT_DIALOG_DATA) public data: AttachmentConfirmationDialogData, + ) {} + + ngOnInit() { + this.file = this.data.file; + this.previewUrl = URL.createObjectURL(this.file); + } + + ngOnDestroy() { + if (this.previewUrl) { + URL.revokeObjectURL(this.previewUrl); + } + } + + get isImage(): boolean { + return this.file?.type?.startsWith('image/') ?? false; + } + + get isPdf(): boolean { + return this.file?.type === 'application/pdf' || this.file?.name?.toLowerCase().endsWith('.pdf'); + } + + get isAudio(): boolean { + return this.file?.type?.startsWith('audio/') ?? false; + } + + dismiss(confirmed: boolean) { + this.dialogRef.close(confirmed); + } + + formatFileSize(size: number): string { + if (size < 1024) { + return `${size} B`; + } + + if (size < 1024 * 1024) { + return `${(size / 1024).toFixed(1)} KB`; + } + + return `${(size / (1024 * 1024)).toFixed(1)} MB`; + } +} diff --git a/src/app/tasks/task-comment-composer/task-comment-composer.component.html b/src/app/tasks/task-comment-composer/task-comment-composer.component.html index c589c6f17e..3d31a0f204 100644 --- a/src/app/tasks/task-comment-composer/task-comment-composer.component.html +++ b/src/app/tasks/task-comment-composer/task-comment-composer.component.html @@ -27,6 +27,23 @@ } +@if (task) { +
+
+ + Editing your comment +
+

Changes can only be saved within 10 minutes of posting.

+
+} + --> @if (isStaff) { + @if (!isEditing) { + + } + } + + @if (!isEditing) { } - - - + @if (!isEditing) { + + }
}
+ @if (isEditing) { + + check + }
- - task + @if (!isEditing) { + + task + }
diff --git a/src/app/tasks/task-comment-composer/task-comment-composer.component.ts b/src/app/tasks/task-comment-composer/task-comment-composer.component.ts index 294f305292..ca7fa0260d 100644 --- a/src/app/tasks/task-comment-composer/task-comment-composer.component.ts +++ b/src/app/tasks/task-comment-composer/task-comment-composer.component.ts @@ -30,6 +30,7 @@ import { import {AlertService} from 'src/app/common/services/alert.service'; import {EmojiService} from 'src/app/common/services/emoji.service'; import {TaskCommentsViewerComponent} from '../task-comments-viewer/task-comments-viewer.component'; +import {AttachmentConfirmationDialogComponent} from './attachment-confirmation-dialog/attachment-confirmation-dialog.component'; interface ApiError { error?: string; @@ -44,6 +45,7 @@ interface ApiError { export interface TaskCommentComposerData { originalComment: TaskComment; + editingComment: TaskComment; } const ACCEPTED_FILE_TYPES = [ @@ -87,6 +89,7 @@ export class TaskCommentComposerComponent implements OnInit, AfterViewInit, DoCh private submittedTaskIds: Set = new Set(); public isSending: boolean = false; + private draftBeforeEdit: string = ''; comment = { text: '', @@ -139,6 +142,7 @@ export class TaskCommentComposerComponent implements OnInit, AfterViewInit, DoCh const newTask = changes.task.currentValue as Task; // Check if the task has changed + this.cancelEdit(); this.cancelReply(); this.clearInput(); @@ -174,6 +178,10 @@ export class TaskCommentComposerComponent implements OnInit, AfterViewInit, DoCh // Update onInputChange to reset submitted status onInputChange(event: Event) { + if (this.isEditing) { + return; + } + const target = event.target as HTMLElement; const text = target.innerText; const raw = target.innerText; @@ -321,11 +329,7 @@ export class TaskCommentComposerComponent implements OnInit, AfterViewInit, DoCh change.forEachChangedItem((item) => { // If it has changed to be an actual comment if (item != null) { - // Set the input field as focused, so the user can start typing - // timeout is required - setTimeout(() => { - this.input.first.nativeElement.focus(); - }); + this.syncComposerState(); } }); } @@ -335,6 +339,14 @@ export class TaskCommentComposerComponent implements OnInit, AfterViewInit, DoCh return this.sharedData.originalComment; } + get editingComment(): TaskComment { + return this.sharedData.editingComment; + } + + get isEditing(): boolean { + return this.editingComment != null; + } + get isStaff() { return this.task?.unit?.currentUserIsStaff; } @@ -343,6 +355,11 @@ export class TaskCommentComposerComponent implements OnInit, AfterViewInit, DoCh this.sharedData.originalComment = null; } + cancelEdit() { + this.sharedData.editingComment = null; + this.restoreDraftAfterEdit(); + } + contentEditableValue() { const UA = navigator.userAgent; const isWebkit = /WebKit/.test(UA) && !/Edge/.test(UA); @@ -372,7 +389,11 @@ export class TaskCommentComposerComponent implements OnInit, AfterViewInit, DoCh this.emojiSearchMode = false; this.showEmojiPicker = false; if (this.input.first.nativeElement.innerText.trim() !== '') { - this.addComment(); + if (this.isEditing) { + this.saveEditedComment(); + } else { + this.addComment(); + } } } @@ -534,6 +555,31 @@ export class TaskCommentComposerComponent implements OnInit, AfterViewInit, DoCh }); } + saveEditedComment() { + if (this.isSending || !this.editingComment) { + return; + } + + this.isSending = true; + const text = this.emojiService.nativeEmojiToColons(this.input.first.nativeElement.innerText); + + this.taskCommentService.editComment(this.editingComment, text).subscribe({ + next: (_tc: TaskComment) => { + this.isSending = false; + this.sharedData.editingComment = null; + this.draftBeforeEdit = ''; + this.clearInput(); + }, + error: (error: ApiError) => { + this.isSending = false; + this.alerts.error( + error.error || error.message || `Failed to edit comment: ${error}`, + 6000, + ); + }, + }); + } + addCommentWithType(comment: string, type: string) { this.taskCommentService.addComment(this.task, comment, type).subscribe({ next: (success: TaskComment) => { @@ -550,18 +596,85 @@ export class TaskCommentComposerComponent implements OnInit, AfterViewInit, DoCh this.uploader.nativeElement.click(); } - uploadFiles(event) { - [...event].forEach((file) => { + handlePaste(event: ClipboardEvent) { + const files = this.getClipboardFiles(event); + + if (files.length === 0) { + return; + } + + const existingText = this.input?.first?.nativeElement?.innerText ?? ''; + event.preventDefault(); + this.clearPastedPlaceholderContent(existingText); + this.uploadFiles(files); + } + + handleBeforeInput(event: InputEvent) { + if (event.inputType !== 'insertFromPaste') { + return; + } + + const files = Array.from(event.dataTransfer?.files ?? []); + + if (files.length === 0) { + return; + } + + const existingText = this.input?.first?.nativeElement?.innerText ?? ''; + event.preventDefault(); + this.clearPastedPlaceholderContent(existingText); + this.uploadFiles(files); + } + + uploadFiles(files: ArrayLike) { + const acceptedFiles: File[] = []; + + Array.from(files).forEach((file) => { if ( ACCEPTED_FILE_TYPES.includes(file.type) || file.type.startsWith('audio/') || file.type.startsWith('image/') ) { - this.postAttachmentComment(file); + acceptedFiles.push(file); } else { this.alerts.error('Cannot upload that file - only images, audio, and PDFs.', 4000); } }); + + this.confirmAttachmentsSequentially(acceptedFiles); + this.resetUploader(); + } + + private getClipboardFiles(event: ClipboardEvent): File[] { + const clipboardData = event.clipboardData; + + if (!clipboardData) { + return []; + } + + const directFiles = Array.from(clipboardData.files ?? []); + if (directFiles.length > 0) { + return directFiles; + } + + return Array.from(clipboardData.items ?? []) + .filter((item) => item.kind === 'file') + .map((item) => item.getAsFile()) + .filter((file): file is File => file != null); + } + + private clearPastedPlaceholderContent(existingText: string) { + if (!this.input?.first?.nativeElement) { + return; + } + + // Let the browser finish the paste event lifecycle, then restore the pre-paste text + // so clipboard attachment placeholders do not replace an in-progress draft. + setTimeout(() => { + this.input.first.nativeElement.innerText = existingText; + this.saveCurrentDraft(); + this.cdRef.detectChanges(); + }); } // # Upload image files as comments to a given task @@ -576,10 +689,103 @@ export class TaskCommentComposerComponent implements OnInit, AfterViewInit, DoCh ); } + private confirmAttachmentsSequentially(files: File[], index: number = 0) { + if (index >= files.length) { + return; + } + + const dialogRef = this.dialog.open(AttachmentConfirmationDialogComponent, { + data: { + file: files[index], + }, + maxWidth: '720px', + width: 'min(92vw, 720px)', + }); + + dialogRef.afterClosed().subscribe((confirmed: boolean) => { + if (confirmed) { + this.postAttachmentComment(files[index]); + } + + this.confirmAttachmentsSequentially(files, index + 1); + }); + } + + private resetUploader() { + if (this.uploader?.nativeElement) { + this.uploader.nativeElement.value = ''; + } + } + showFeedbackPicker() { this.showFeedbackTemplatePicker = !this.showFeedbackTemplatePicker; this.commentsViewer.scrollDown(); } + + private syncComposerState() { + if (this.isEditing) { + this.beginEditingComment(); + return; + } + + setTimeout(() => { + this.input.first.nativeElement.focus(); + }); + } + + private beginEditingComment() { + const currentText = this.input?.first?.nativeElement?.innerText ?? ''; + const nextText = this.editingComment?.text ?? ''; + + if (this.sharedData.originalComment != null) { + this.sharedData.originalComment = null; + } + + if (currentText !== nextText) { + this.draftBeforeEdit = currentText; + this.setComposerText(nextText); + } + + setTimeout(() => { + this.focusComposerAtEnd(); + }); + } + + private restoreDraftAfterEdit() { + const draft = this.draftBeforeEdit; + this.draftBeforeEdit = ''; + this.setComposerText(draft); + } + + private setComposerText(text: string) { + if (!this.input?.first?.nativeElement) { + return; + } + + this.input.first.nativeElement.innerText = text; + this.cdRef.detectChanges(); + } + + private focusComposerAtEnd() { + const element = this.input?.first?.nativeElement; + if (!element) { + return; + } + + element.focus(); + + const selection = window.getSelection(); + if (!selection) { + return; + } + + const range = document.createRange(); + range.selectNodeContents(element); + range.collapse(false); + + selection.removeAllRanges(); + selection.addRange(range); + } } // The discussion prompt composer dialog Component diff --git a/src/app/tasks/task-comments-viewer/comment-bubble-action/comment-bubble-action.component.html b/src/app/tasks/task-comments-viewer/comment-bubble-action/comment-bubble-action.component.html index fa43ccb7ee..5bfa75a49e 100644 --- a/src/app/tasks/task-comments-viewer/comment-bubble-action/comment-bubble-action.component.html +++ b/src/app/tasks/task-comments-viewer/comment-bubble-action/comment-bubble-action.component.html @@ -1,4 +1,4 @@ -
+
- - - -
- -
-
-
- -
-
- - -
-
-
-
- -
-
- - - -
-
-

- Click the button twice to reverse the sort ordering. -

-
-
-
-
-

No students found

-

- No students were found using the filters specified. -

-
-
- - - - - - - - - - - - - - - - - - - - - - - -
- - Username - - - - Name - - - - Stats - - - - Flags - - - - - Campus - - - - Tutorial - -
- - - {{project.student.username || "N/A"}} - - {{project.student.name}} - - - - {{bar.value !== bar.value ? 'No Interaction' : (bar.value + '%')}} - - - - - - - - - - - - - - - - - - -
- - - diff --git a/src/app/units/states/tasks/inbox/directives/staff-task-list/batch-feedback-workflow-dialog/batch-feedback-workflow-dialog.component.html b/src/app/units/states/tasks/inbox/directives/staff-task-list/batch-feedback-workflow-dialog/batch-feedback-workflow-dialog.component.html new file mode 100644 index 0000000000..daa4a5723b --- /dev/null +++ b/src/app/units/states/tasks/inbox/directives/staff-task-list/batch-feedback-workflow-dialog/batch-feedback-workflow-dialog.component.html @@ -0,0 +1,36 @@ +

Upload Batch Feedback Zip — {{ taskLabel }}

+ + +
+ fact_check +
+

+ Prepare a batch feedback zip for {{ taskLabel }}. +

+

+ Include a marks.csv file and a folder for each student using their username. +

+
+
+ +
+

Each username folder should contain a PDF named after the student username.

+

We expect files to follow the pattern username/username.pdf.

+
+ +
+

marks.csv Columns

+

+ Student Username | Student ID | Status | + Comment +

+
+
+ + + + + diff --git a/src/app/units/states/tasks/inbox/directives/staff-task-list/batch-feedback-workflow-dialog/batch-feedback-workflow-dialog.component.ts b/src/app/units/states/tasks/inbox/directives/staff-task-list/batch-feedback-workflow-dialog/batch-feedback-workflow-dialog.component.ts new file mode 100644 index 0000000000..0ecb5fb1e6 --- /dev/null +++ b/src/app/units/states/tasks/inbox/directives/staff-task-list/batch-feedback-workflow-dialog/batch-feedback-workflow-dialog.component.ts @@ -0,0 +1,36 @@ +import {Component, Inject} from '@angular/core'; +import {MAT_DIALOG_DATA, MatDialogRef} from '@angular/material/dialog'; +import {TaskDefinition, Unit} from 'src/app/api/models/doubtfire-model'; + +export interface BatchFeedbackWorkflowDialogData { + unit: Unit; + taskDefinition?: TaskDefinition; + myStudentsOnly?: boolean; +} + +@Component({ + selector: 'f-batch-feedback-workflow-dialog', + templateUrl: './batch-feedback-workflow-dialog.component.html', +}) +export class BatchFeedbackWorkflowDialogComponent { + constructor( + @Inject(MAT_DIALOG_DATA) public data: BatchFeedbackWorkflowDialogData, + private dialogRef: MatDialogRef, + ) {} + + get taskLabel(): string { + if (!this.data.taskDefinition) { + return this.data.unit.code; + } + + return `${this.data.taskDefinition.abbreviation} ${this.data.taskDefinition.name}`; + } + + continueToUpload(): void { + this.dialogRef.close({openUpload: true}); + } + + close(): void { + this.dialogRef.close(); + } +} diff --git a/src/app/units/states/tasks/inbox/directives/staff-task-list/staff-task-list.component.html b/src/app/units/states/tasks/inbox/directives/staff-task-list/staff-task-list.component.html index 5912585804..60f1ee14df 100644 --- a/src/app/units/states/tasks/inbox/directives/staff-task-list/staff-task-list.component.html +++ b/src/app/units/states/tasks/inbox/directives/staff-task-list/staff-task-list.component.html @@ -79,6 +79,12 @@ download Bulk Export Submission Files + @if (unitRole?.role === 'Convenor') { + + } - + + + - - - - - } + + diff --git a/src/app/units/states/tasks/inbox/directives/staff-task-list/staff-task-list.component.ts b/src/app/units/states/tasks/inbox/directives/staff-task-list/staff-task-list.component.ts index 1d3c166609..ed624bd838 100644 --- a/src/app/units/states/tasks/inbox/directives/staff-task-list/staff-task-list.component.ts +++ b/src/app/units/states/tasks/inbox/directives/staff-task-list/staff-task-list.component.ts @@ -10,13 +10,16 @@ import { ViewChild, TemplateRef, OnDestroy, + Inject, } from '@angular/core'; import {TasksOfTaskDefinitionPipe} from 'src/app/common/filters/tasks-of-task-definition.pipe'; import {TasksInTutorialsPipe} from 'src/app/common/filters/tasks-in-tutorials.pipe'; import {TasksForInboxSearchPipe} from 'src/app/common/filters/tasks-for-inbox-search.pipe'; import {MatDialog} from '@angular/material/dialog'; +import {csvResultModalService, csvUploadModalService} from 'src/app/ajs-upgraded-providers'; import {Unit} from 'src/app/api/models/unit'; import {UnitRole} from 'src/app/api/models/unit-role'; +import {SidekiqJob} from 'src/app/api/models/sidekiq-job'; import { Tutorial, UserService, @@ -35,6 +38,7 @@ import {Router} from '@angular/router'; import {TaskDefinitionService} from 'src/app/api/services/task-definition.service'; import {SidekiqProgressModalService} from 'src/app/common/modals/sidekiq-progress-modal/sidekiq-progress-modal.service'; import {TasksByTutorPipe} from 'src/app/common/filters/tasks-by-tutor.pipe'; +import {BatchFeedbackWorkflowDialogComponent} from './batch-feedback-workflow-dialog/batch-feedback-workflow-dialog.component'; @Component({ selector: 'df-staff-task-list', @@ -128,6 +132,8 @@ export class StaffTaskListComponent implements OnInit, OnChanges, OnDestroy { private alertService: AlertService, private fileDownloaderService: FileDownloaderService, public dialog: MatDialog, + @Inject(csvUploadModalService) private csvUploadModal: any, + @Inject(csvResultModalService) private csvResultModal: any, private userService: UserService, private hotkeys: HotkeysService, private router: Router, @@ -319,6 +325,59 @@ export class StaffTaskListComponent implements OnInit, OnChanges, OnDestroy { }); } + openBatchFeedbackDialog() { + const taskDefinition = this.filters.taskDefinition ?? undefined; + + if (!taskDefinition) { + this.alertService.error('Select a task definition before uploading batch feedback.', 5000); + return; + } + + const dialogRef = this.dialog.open(BatchFeedbackWorkflowDialogComponent, { + width: '100%', + maxWidth: '840px', + data: { + unit: this.unit, + taskDefinition, + myStudentsOnly: this.filters.tutorialIdSelected === 'mine', + }, + }); + + dialogRef.afterClosed().subscribe((result) => { + if (!result?.openUpload) { + return; + } + + this.csvUploadModal.show( + `Upload ${taskDefinition.abbreviation} Batch Feedback Zip`, + '', + { + file: {name: 'Batch Feedback Zip', type: 'zip'}, + }, + this.unit.getBatchFeedbackUploadUrl(taskDefinition), + (response: SidekiqJob) => { + if (!response?.id) { + this.alertService.error('Batch feedback upload failed.', 6000); + return; + } + + this.sidekiqProgressModalService + .show(`Uploading ${taskDefinition.abbreviation} Batch Feedback`, response.id) + .subscribe({ + next: (job) => { + this.csvResultModal.show('Batch Feedback Upload Results', JSON.parse(job.result)); + this.refreshData(); + }, + error: (error) => { + console.error(error); + this.alertService.error('Batch feedback upload failed.', 6000); + }, + }); + }, + ); + }); + } + downloadJPLAGReport() { const taskDef = this.filters.taskDefinition; this.fileDownloaderService.downloadFile( @@ -359,6 +418,7 @@ export class StaffTaskListComponent implements OnInit, OnChanges, OnDestroy { } filteredTasks = this.taskWithStudentNamePipe.transform(filteredTasks, this.filters.studentName); + filteredTasks = this.sortPinnedTasksFirst(filteredTasks); this.filteredTasks = filteredTasks; if (this.filteredTasks != null) { @@ -563,7 +623,13 @@ export class StaffTaskListComponent implements OnInit, OnChanges, OnDestroy { } togglePin(task: Task) { - task.pinned ? task.unpin() : task.pin(); + if (task.id === undefined) { + // Can't pin a task that doesn't actually exist yet + this.alertService.error(`This task can't be pinned yet`, 3000); + return; + } + const refreshOrdering = () => this.applyFilters(); + task.pinned ? task.unpin(refreshOrdering) : task.pin(refreshOrdering); } getWarningIcon(task: Task): 'warning' | 'overflow' | null { @@ -572,10 +638,7 @@ export class StaffTaskListComponent implements OnInit, OnChanges, OnDestroy { return null; } - const now = Date.now(); - const submission = new Date(task.submissionDate).getTime(); - - const daysSinceSubmission = (now - submission) / (1000 * 60 * 60 * 24); + const daysSinceSubmission = task.daysSinceSubmission(); if (daysSinceSubmission >= task.unit.feedbackOverflowThresholdDays) { return 'overflow'; @@ -587,4 +650,12 @@ export class StaffTaskListComponent implements OnInit, OnChanges, OnDestroy { return null; } + + private sortPinnedTasksFirst(tasks: Task[]): Task[] { + if (!this.isTaskDefMode || !tasks?.length) { + return tasks; + } + + return [...tasks].sort((a, b) => Number(b.pinned) - Number(a.pinned)); + } } diff --git a/src/app/units/states/tasks/tasks.coffee b/src/app/units/states/tasks/tasks.coffee index d4bff19b2e..eb9062f876 100644 --- a/src/app/units/states/tasks/tasks.coffee +++ b/src/app/units/states/tasks/tasks.coffee @@ -52,9 +52,10 @@ angular.module('doubtfire.units.states.tasks', [ # inside the task inbox list $scope.taskData.taskKey = newTaskService.taskKeyFromString(taskKeyString) - # Child states will use taskKey to notify what task has been - # selected by the child on first load. - taskKey = $transition$.params().taskKey + # During rapid route changes in the hybrid router, the controller can be + # instantiated without an active transition. Fall back to the state's + # current params so the route still initializes safely. + taskKey = $transition$?.params?().taskKey ? $state.params.taskKey setTaskKeyFromUrlParams(taskKey) # Whenever the state is changed, we look at the taskKey in the URL params