From 45e6141e8c2b68f33b7f5687bfbd33a9c929973f Mon Sep 17 00:00:00 2001 From: Boink <40929320+b0ink@users.noreply.github.com> Date: Tue, 24 Mar 2026 12:39:46 +1100 Subject: [PATCH 01/58] refactor: confirm recursive fix and resubmit for dependent tasks (#1132) * refactor: confirm recursive fix and resubmit for dependent tasks * refactor: move logic to task model --- src/app/api/models/task.ts | 65 ++++++++++++++++++- src/app/common/footer/footer.component.html | 2 +- src/app/common/footer/footer.component.ts | 36 ++++++++++ .../confirmation-modal.component.html | 4 +- .../confirmation-modal.component.ts | 17 ++++- .../confirmation-modal.service.ts | 8 ++- 6 files changed, 124 insertions(+), 8 deletions(-) diff --git a/src/app/api/models/task.ts b/src/app/api/models/task.ts index 2d7d995792..1ef90ca39e 100644 --- a/src/app/api/models/task.ts +++ b/src/app/api/models/task.ts @@ -24,10 +24,11 @@ import { 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', @@ -681,6 +682,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; } @@ -826,7 +879,11 @@ export class Task extends Entity { }); } - public updateTaskStatus(status: TaskStatusEnum, markAsDiscussed?: boolean) { + public updateTaskStatus( + status: TaskStatusEnum, + markAsDiscussed?: boolean, + triggerRecursiveFix?: boolean, + ) { const oldStatus = this.status; const alerts: AlertService = AppInjector.get(AlertService); @@ -851,6 +908,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 diff --git a/src/app/common/footer/footer.component.html b/src/app/common/footer/footer.component.html index cb6e238c38..fb9eca0065 100644 --- a/src/app/common/footer/footer.component.html +++ b/src/app/common/footer/footer.component.html @@ -45,7 +45,7 @@ matTooltipPosition="above" aria-label="" class="button large-button" - (click)="selectedTask?.updateTaskStatus('fix')" + (click)="markAsResubmit(selectedTask)" > construction diff --git a/src/app/common/footer/footer.component.ts b/src/app/common/footer/footer.component.ts index 7626b63a6d..aab80e6fd5 100644 --- a/src/app/common/footer/footer.component.ts +++ b/src/app/common/footer/footer.component.ts @@ -7,6 +7,9 @@ import {FileDownloaderService} from '../file-downloader/file-downloader.service' import {TaskAssessmentModalService} from '../modals/task-assessment-modal/task-assessment-modal.service'; import {UnitRole} from 'src/app/api/models/unit-role'; import {UserService} from 'src/app/api/services/user.service'; +import {ProjectService} from 'src/app/api/services/project.service'; +import {ConfirmationModalService} from '../modals/confirmation-modal/confirmation-modal.service'; +import {AlertService} from '../services/alert.service'; @Component({ selector: 'f-footer', @@ -20,6 +23,9 @@ export class FooterComponent implements OnInit { private fileDownloader: FileDownloaderService, private taskAssessmentModal: TaskAssessmentModalService, private userService: UserService, + private projectService: ProjectService, + private confirmationModalService: ConfirmationModalService, + private alertService: AlertService, ) {} @Input() viewType: 'inbox' | 'explorer' | 'moderation' | 'overflow'; @@ -191,4 +197,34 @@ export class FooterComponent implements OnInit { public toggleModerationStatusButtons() { this.showModerationStatusButtons = !this.showModerationStatusButtons; } + + async markAsResubmit(task: Task) { + if (!task?.definition || !task?.project) { + return; + } + + try { + const hasReadyDependents = await task.hasReadyForFeedbackDependents(); + if (!hasReadyDependents) { + task.updateTaskStatus('fix_and_resubmit'); + return; + } + + this.confirmationModalService.show( + 'Move dependent tasks to Fix and Resubmit?', + 'This task is a prerequisite for one or more other tasks submitted by this student that are Ready for Feedback. Do you want to move those tasks to Fix and Resubmit as well?', + () => { + task.updateTaskStatus('fix_and_resubmit', false, true); + }, + () => { + task.updateTaskStatus('fix_and_resubmit'); + }, + 'Yes, update dependent tasks', + 'No, just this task', + ); + } catch (error) { + this.alertService.error(`Failed to check dependent task statuses: ${error}`, 6000); + task.updateTaskStatus('fix_and_resubmit'); + } + } } diff --git a/src/app/common/modals/confirmation-modal/confirmation-modal.component.html b/src/app/common/modals/confirmation-modal/confirmation-modal.component.html index 3ec07fc729..c976c7c75a 100644 --- a/src/app/common/modals/confirmation-modal/confirmation-modal.component.html +++ b/src/app/common/modals/confirmation-modal/confirmation-modal.component.html @@ -13,8 +13,8 @@

- + 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%', From 04f4d30043acf3d363acac2b141e84bdc4c11e41 Mon Sep 17 00:00:00 2001 From: b0ink <40929320+b0ink@users.noreply.github.com> Date: Tue, 24 Mar 2026 12:40:24 +1100 Subject: [PATCH 02/58] chore(release): 10.0.1-23 --- CHANGELOG.md | 2 ++ package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a119c3b7d..7131db231d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ 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-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..a94f140396 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "doubtfire", - "version": "10.0.1-22", + "version": "10.0.1-23", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "doubtfire", - "version": "10.0.1-22", + "version": "10.0.1-23", "license": "AGPL-3.0", "dependencies": { "@angular/animations": "^17.3.6", diff --git a/package.json b/package.json index 6a353dbcaa..b352e08f51 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "doubtfire", - "version": "10.0.1-22", + "version": "10.0.1-23", "homepage": "http://github.com/doubtfire-lms/", "description": "Learning and feedback tool.", "license": "AGPL-3.0", From b64601a425c7a64bccfaf47c1e4fccb0343b1589 Mon Sep 17 00:00:00 2001 From: b0ink <40929320+b0ink@users.noreply.github.com> Date: Tue, 24 Mar 2026 14:31:42 +1100 Subject: [PATCH 03/58] feat: confirm recursive fix in mobile tutor view --- .../tutor-discussion.component.ts | 44 ++++++++++++++++++- 1 file changed, 43 insertions(+), 1 deletion(-) 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..84095a5436 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,7 @@ import { UnitService, UserService, } from 'src/app/api/models/doubtfire-model'; +import {ConfirmationModalService} from 'src/app/common/modals/confirmation-modal/confirmation-modal.service'; import {AlertService} from 'src/app/common/services/alert.service'; import {GradeService} from 'src/app/common/services/grade.service'; @@ -66,6 +67,7 @@ export class TutorDiscussionComponent implements AfterViewInit { private gradeService: GradeService, private state: StateService, private alertService: AlertService, + private confirmationModalService: ConfirmationModalService, private route: UIRouter, private taskCommentService: TaskCommentService, private taskService: TaskService, @@ -262,7 +264,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 +281,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 +330,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); } From f50a58e9737ce32eef772c48ef936f7819a1b9e1 Mon Sep 17 00:00:00 2001 From: b0ink <40929320+b0ink@users.noreply.github.com> Date: Tue, 24 Mar 2026 14:32:08 +1100 Subject: [PATCH 04/58] chore(release): 10.0.1-24 --- CHANGELOG.md | 7 +++++++ package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7131db231d..876c5cdbf4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ 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-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 a94f140396..92ccf1dec9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "doubtfire", - "version": "10.0.1-23", + "version": "10.0.1-24", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "doubtfire", - "version": "10.0.1-23", + "version": "10.0.1-24", "license": "AGPL-3.0", "dependencies": { "@angular/animations": "^17.3.6", diff --git a/package.json b/package.json index b352e08f51..17bf3a09bd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "doubtfire", - "version": "10.0.1-23", + "version": "10.0.1-24", "homepage": "http://github.com/doubtfire-lms/", "description": "Learning and feedback tool.", "license": "AGPL-3.0", From 34fed4772fc2ed14586f53b6fac4ad695a18eb90 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 24 Mar 2026 04:42:37 +0000 Subject: [PATCH 05/58] build(deps): bump angular-sanitize from 1.5.11 to 1.8.3 Bumps [angular-sanitize](https://github.com/angular/angular.js) from 1.5.11 to 1.8.3. - [Changelog](https://github.com/angular/angular.js/blob/master/CHANGELOG.md) - [Commits](https://github.com/angular/angular.js/compare/v1.5.11...v1.8.3) --- updated-dependencies: - dependency-name: angular-sanitize dependency-version: 1.8.3 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- package-lock.json | 8 +++++--- package.json | 2 +- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index 92ccf1dec9..484310a5bc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -40,7 +40,7 @@ "angular-mocks": "1.8.3", "angular-nvd3": "1.0.9", "angular-resource": "1.5.11", - "angular-sanitize": "1.5.11", + "angular-sanitize": "1.8.3", "angular-ui-bootstrap": "0.13.4", "angular-ui-codemirror": "0.3.0", "angular-xeditable": "0.9.0", @@ -6611,7 +6611,10 @@ "license": "MIT" }, "node_modules/angular-sanitize": { - "version": "1.5.11", + "version": "1.8.3", + "resolved": "https://registry.npmjs.org/angular-sanitize/-/angular-sanitize-1.8.3.tgz", + "integrity": "sha512-2rxdqzlUVafUeWOwvY/FtyWk1pFTyCtzreeiTytG9m4smpuAEKaIJAjYeVwWsoV+nlTOcgpwV4W1OCmR+BQbUg==", + "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": { @@ -11200,7 +11203,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": [ diff --git a/package.json b/package.json index 17bf3a09bd..4f08306194 100644 --- a/package.json +++ b/package.json @@ -59,7 +59,7 @@ "angular-mocks": "1.8.3", "angular-nvd3": "1.0.9", "angular-resource": "1.5.11", - "angular-sanitize": "1.5.11", + "angular-sanitize": "1.8.3", "angular-ui-bootstrap": "0.13.4", "angular-ui-codemirror": "0.3.0", "angular-xeditable": "0.9.0", From 248c992f46488f61916e00a87ca32ed987721f31 Mon Sep 17 00:00:00 2001 From: b0ink <40929320+b0ink@users.noreply.github.com> Date: Wed, 25 Mar 2026 12:19:11 +1100 Subject: [PATCH 06/58] feat: display sso redirecting state --- src/app/sessions/states/sign-in/sign-in.component.html | 9 ++++++++- src/app/sessions/states/sign-in/sign-in.component.ts | 5 +++++ 2 files changed, 13 insertions(+), 1 deletion(-) 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 { From 12fbf8147107dc185a9a9cbea940efac93a0f316 Mon Sep 17 00:00:00 2001 From: Boink <40929320+b0ink@users.noreply.github.com> Date: Thu, 26 Mar 2026 13:05:50 +1100 Subject: [PATCH 07/58] feat: pause feedback threshold during teaching period breaks (#1138) * feat: pause feedback threshold during teaching period breaks * chore: round down days --- src/app/api/models/task.ts | 43 +++++++++++++++++++ .../staff-task-list.component.ts | 5 +-- 2 files changed, 44 insertions(+), 4 deletions(-) diff --git a/src/app/api/models/task.ts b/src/app/api/models/task.ts index 1ef90ca39e..26488452d3 100644 --- a/src/app/api/models/task.ts +++ b/src/app/api/models/task.ts @@ -152,6 +152,49 @@ export class Task extends Entity { 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. * 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..3b65b4247e 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 @@ -572,10 +572,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'; From 01a407e99ecaf66ea4fed5babba5a9ae091ab80f Mon Sep 17 00:00:00 2001 From: b0ink <40929320+b0ink@users.noreply.github.com> Date: Fri, 27 Mar 2026 10:41:15 +1100 Subject: [PATCH 08/58] chore: ensure tasks not in overflow are filtered out - this may be due to the breaks re-calculated client-side --- src/app/api/services/task.service.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/app/api/services/task.service.ts b/src/app/api/services/task.service.ts index 8aaa3805a2..d3d517fe1e 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); }), From 5eb855fb03273e1bcc73ffb983ab3f2e44e7ddb9 Mon Sep 17 00:00:00 2001 From: b0ink <40929320+b0ink@users.noreply.github.com> Date: Fri, 27 Mar 2026 10:42:30 +1100 Subject: [PATCH 09/58] chore(release): 10.0.1-25 --- CHANGELOG.md | 8 ++++++++ package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 876c5cdbf4..466f028547 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ 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-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) diff --git a/package-lock.json b/package-lock.json index 92ccf1dec9..f5b9dd4f50 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "doubtfire", - "version": "10.0.1-24", + "version": "10.0.1-25", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "doubtfire", - "version": "10.0.1-24", + "version": "10.0.1-25", "license": "AGPL-3.0", "dependencies": { "@angular/animations": "^17.3.6", diff --git a/package.json b/package.json index 17bf3a09bd..2385105a13 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "doubtfire", - "version": "10.0.1-24", + "version": "10.0.1-25", "homepage": "http://github.com/doubtfire-lms/", "description": "Learning and feedback tool.", "license": "AGPL-3.0", From a9bd5c6ad1cb9d63ad5e7e9c67ff86b9496f8875 Mon Sep 17 00:00:00 2001 From: b0ink <40929320+b0ink@users.noreply.github.com> Date: Fri, 27 Mar 2026 10:50:41 +1100 Subject: [PATCH 10/58] chore: display days since submission in tooltip --- .../staff-task-list/staff-task-list.component.html | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) 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..2f96b676bb 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 @@ -269,7 +269,11 @@

} @else if (getWarningIcon(task) === 'overflow') { watch_later From 6d283c805d209447821526b785d7341272aac544 Mon Sep 17 00:00:00 2001 From: b0ink <40929320+b0ink@users.noreply.github.com> Date: Fri, 27 Mar 2026 10:51:47 +1100 Subject: [PATCH 11/58] chore(release): 10.0.1-26 --- CHANGELOG.md | 2 ++ package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 466f028547..9f6d6fd1f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ 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-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) diff --git a/package-lock.json b/package-lock.json index f5b9dd4f50..fafb6cfb40 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "doubtfire", - "version": "10.0.1-25", + "version": "10.0.1-26", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "doubtfire", - "version": "10.0.1-25", + "version": "10.0.1-26", "license": "AGPL-3.0", "dependencies": { "@angular/animations": "^17.3.6", diff --git a/package.json b/package.json index 2385105a13..7fb9743a25 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "doubtfire", - "version": "10.0.1-25", + "version": "10.0.1-26", "homepage": "http://github.com/doubtfire-lms/", "description": "Learning and feedback tool.", "license": "AGPL-3.0", From fe1813f2bcf63fb1d448bc5e1606e09d557048f3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 27 Mar 2026 00:47:30 +0000 Subject: [PATCH 12/58] build(deps-dev): bump node-forge from 1.3.3 to 1.4.0 Bumps [node-forge](https://github.com/digitalbazaar/forge) from 1.3.3 to 1.4.0. - [Changelog](https://github.com/digitalbazaar/forge/blob/main/CHANGELOG.md) - [Commits](https://github.com/digitalbazaar/forge/compare/v1.3.3...v1.4.0) --- updated-dependencies: - dependency-name: node-forge dependency-version: 1.4.0 dependency-type: indirect ... Signed-off-by: dependabot[bot] --- package-lock.json | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index fafb6cfb40..ca3c60ad20 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11200,7 +11200,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": [ @@ -16396,9 +16395,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": { From 27e9733193afc41f353e2f773edb7bb63ebe2fce Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 27 Mar 2026 13:55:39 +0000 Subject: [PATCH 13/58] build(deps-dev): bump brace-expansion from 1.1.11 to 1.1.13 Bumps [brace-expansion](https://github.com/juliangruber/brace-expansion) from 1.1.11 to 1.1.13. - [Release notes](https://github.com/juliangruber/brace-expansion/releases) - [Commits](https://github.com/juliangruber/brace-expansion/compare/1.1.11...v1.1.13) --- updated-dependencies: - dependency-name: brace-expansion dependency-version: 1.1.13 dependency-type: indirect ... Signed-off-by: dependabot[bot] --- package-lock.json | 57 ++++++++++++++++++++++++++++++++++------------- 1 file changed, 42 insertions(+), 15 deletions(-) diff --git a/package-lock.json b/package-lock.json index fafb6cfb40..b185c46c35 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3413,7 +3413,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 +3497,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": { @@ -7580,7 +7584,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 +10087,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 +10261,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": { @@ -11200,7 +11210,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 +11455,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 +11602,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": { @@ -12761,7 +12774,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 +14247,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": { @@ -14550,7 +14567,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": { @@ -14867,7 +14886,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 +14959,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": { @@ -16062,7 +16085,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": { @@ -21543,7 +21568,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": { From da2de980f3fdb438fde79c00798c67dd4a43e660 Mon Sep 17 00:00:00 2001 From: b0ink <40929320+b0ink@users.noreply.github.com> Date: Sun, 29 Mar 2026 16:52:40 +1100 Subject: [PATCH 14/58] refactor: add colors to status action buttons --- src/app/common/footer/footer.component.html | 24 ++++---- src/app/common/footer/footer.component.scss | 63 +++++++++++++++++++-- 2 files changed, 72 insertions(+), 15 deletions(-) diff --git a/src/app/common/footer/footer.component.html b/src/app/common/footer/footer.component.html index fb9eca0065..e0191fce76 100644 --- a/src/app/common/footer/footer.component.html +++ b/src/app/common/footer/footer.component.html @@ -12,7 +12,7 @@ } - +
- +
} + } @@ -76,7 +79,8 @@ matTooltip="Mark as Discuss" matTooltipPosition="above" aria-label="" - class="button large-button status-chip discuss" + class="button status-chip discuss" + [class.large-button]="!(selectedTask && selectedTask.suggestedTaskStatus)" (click)="selectedTask?.updateTaskStatus('discuss')" > question_answer diff --git a/src/app/common/footer/footer.component.scss b/src/app/common/footer/footer.component.scss index 0f22019bb2..67a66a9e72 100644 --- a/src/app/common/footer/footer.component.scss +++ b/src/app/common/footer/footer.component.scss @@ -74,6 +74,26 @@ color: #000; } + .extra-large-button.status-chip.suggested-task-status { + background-color: var(--status-chip-bg); + color: #fff; + filter: none; + } + + .extra-large-button.status-chip.suggested-task-status.fix-and-resubmit { + color: #000; + } + + .extra-large-button.status-chip.suggested-task-status:hover { + background-color: var(--status-chip-bg); + color: #fff; + filter: none; + } + + .extra-large-button.status-chip.suggested-task-status.fix-and-resubmit:hover { + color: #000; + } + .extra-large-button.status-chip:hover { background-color: var(--status-chip-bg); color: #fff; @@ -144,6 +164,7 @@ } &.discuss { @include status-icon('discuss'); + color: #000; } &.working-on-it { @include status-icon('working-on-it'); From a68d757710119e4337a4f2a29b67ed293be6ae5c Mon Sep 17 00:00:00 2001 From: b0ink <40929320+b0ink@users.noreply.github.com> Date: Wed, 1 Apr 2026 13:45:06 +1100 Subject: [PATCH 17/58] refactor: change wording to submissions waiting for discussion --- .../directives/task-due-card/task-due-card.component.html | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) 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 From ad442dd31133c6e9bcbc354d4c66fc4c9ce89a6b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 2 Apr 2026 11:41:48 +0000 Subject: [PATCH 18/58] build(deps): bump lodash from 4.17.21 to 4.18.1 Bumps [lodash](https://github.com/lodash/lodash) from 4.17.21 to 4.18.1. - [Release notes](https://github.com/lodash/lodash/releases) - [Commits](https://github.com/lodash/lodash/compare/4.17.21...4.18.1) --- updated-dependencies: - dependency-name: lodash dependency-version: 4.18.1 dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- package-lock.json | 34 ++++++++++++++++++++++++++++++++-- package.json | 2 +- 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 169f01ca68..ab44f091d0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -60,7 +60,7 @@ "html5-qrcode": "^2.3.8", "jquery": "2.1.4", "jszip": "^3.10.1", - "lodash": "~4.17", + "lodash": "~4.18", "lottie-web": "^5.13.0", "marked": "^11.1.0", "moment": "^2.29.4", @@ -12539,6 +12539,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, @@ -12550,6 +12557,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, @@ -12572,6 +12586,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, @@ -14650,6 +14671,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, @@ -15358,7 +15386,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": { diff --git a/package.json b/package.json index 03f925ec90..7287385245 100644 --- a/package.json +++ b/package.json @@ -79,7 +79,7 @@ "html5-qrcode": "^2.3.8", "jquery": "2.1.4", "jszip": "^3.10.1", - "lodash": "~4.17", + "lodash": "~4.18", "lottie-web": "^5.13.0", "marked": "^11.1.0", "moment": "^2.29.4", From 4fe65d2b6fc00445ce496cfaa39be7074bb262dc Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 12 Apr 2026 11:11:18 +0000 Subject: [PATCH 19/58] build(deps-dev): bump axios from 1.13.6 to 1.15.0 Bumps [axios](https://github.com/axios/axios) from 1.13.6 to 1.15.0. - [Release notes](https://github.com/axios/axios/releases) - [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md) - [Commits](https://github.com/axios/axios/compare/v1.13.6...v1.15.0) --- updated-dependencies: - dependency-name: axios dependency-version: 1.15.0 dependency-type: indirect ... Signed-off-by: dependabot[bot] --- package-lock.json | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/package-lock.json b/package-lock.json index 169f01ca68..4a302d94a6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7140,15 +7140,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": { @@ -18756,9 +18756,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", From 5f90e9085d69913eced55ad54ce9ac09431d3822 Mon Sep 17 00:00:00 2001 From: b0ink <40929320+b0ink@users.noreply.github.com> Date: Mon, 13 Apr 2026 15:51:53 +1000 Subject: [PATCH 20/58] feat: confirmation modal to reassign tutorials when removing staff --- .../unit-staff-editor.component.ts | 66 ++++++++++++++++++- 1 file changed, 65 insertions(+), 1 deletion(-) diff --git a/src/app/units/states/edit/directives/unit-staff-editor/unit-staff-editor.component.ts b/src/app/units/states/edit/directives/unit-staff-editor/unit-staff-editor.component.ts index 5eab52fae3..d481e6cb49 100644 --- a/src/app/units/states/edit/directives/unit-staff-editor/unit-staff-editor.component.ts +++ b/src/app/units/states/edit/directives/unit-staff-editor/unit-staff-editor.component.ts @@ -2,13 +2,14 @@ import {Component, Input, OnInit} from '@angular/core'; import {AlertService} from 'src/app/common/services/alert.service'; import {UnitRoleService} from 'src/app/api/services/unit-role.service'; import {Unit} from 'src/app/api/models/unit'; -import {User} from 'src/app/api/models/doubtfire-model'; +import {Tutorial, User} from 'src/app/api/models/doubtfire-model'; import {UnitRole} from 'src/app/api/models/unit-role'; import {MatTableDataSource} from '@angular/material/table'; import {MatButtonToggleChange} from '@angular/material/button-toggle'; import {ConfirmationModalService} from 'src/app/common/modals/confirmation-modal/confirmation-modal.service'; import {MatSelectChange} from '@angular/material/select'; import {TutorNotesModalService} from 'src/app/common/modals/tutor-notes-modal/tutor-notes-modal.service'; +import {UserService} from 'src/app/api/services/user.service'; @Component({ selector: 'unit-staff-editor', @@ -39,6 +40,7 @@ export class UnitStaffEditorComponent implements OnInit { constructor( private alertService: AlertService, private unitRoleService: UnitRoleService, + private userService: UserService, private confirmationModalService: ConfirmationModalService, private tutorNotesModal: TutorNotesModalService, ) {} @@ -210,6 +212,47 @@ export class UnitStaffEditorComponent implements OnInit { * @returns void */ removeStaff(staff: UnitRole) { + const assignedTutorials = this.tutorialsForUnitRole(staff); + + if (assignedTutorials.length > 0) { + const targetRole = this.reassignmentTargetFor(staff); + + if (!targetRole) { + this.alertService.error( + 'Unable to reassign tutorials because there is no valid staff member to receive them.', + 6000, + ); + return; + } + + const tutorialList = assignedTutorials.map((tutorial) => tutorial.abbreviation).join(', '); + + this.confirmationModalService.show( + 'Reassign Tutorials', + `You cannot remove ${staff.user.name} from the unit as they tutor the following tutorials: ${tutorialList}.`, + () => { + this.unitRoleService + .delete(staff, { + cache: this.unit.staffCache, + params: {reassign_to_unit_role_id: targetRole.id}, + }) + .subscribe({ + next: () => { + assignedTutorials.forEach((tutorial) => { + tutorial.tutor = targetRole.user; + }); + this.alertService.success('Staff member removed and tutorials reassigned', 2000); + }, + error: (response) => this.alertService.error(response, 6000), + }); + }, + undefined, + 'Reassign to me', + 'Cancel', + ); + return; + } + this.confirmationModalService.show( 'Remove staff member', `Are you sure you want to remove ${staff.user.name} from ${this.unit.code} ${this.unit.name}?`, @@ -222,6 +265,27 @@ export class UnitStaffEditorComponent implements OnInit { ); } + private tutorialsForUnitRole(unitRole: UnitRole): Tutorial[] { + return this.unit.tutorials.filter((tutorial) => tutorial.tutor?.id === unitRole.user.id); + } + + private reassignmentTargetFor(unitRole: UnitRole): UnitRole | undefined { + const currentUserRole = this.unit.staff.find( + (staffRole) => + staffRole.user.id === this.userService.currentUser.id && staffRole.id !== unitRole.id, + ); + + if (currentUserRole) { + return currentUserRole; + } + + if (this.unit.mainConvenor?.id && this.unit.mainConvenor.id !== unitRole.id) { + return this.unit.mainConvenor; + } + + return undefined; + } + groupSetName(id: number) { this.unit.groupSetsCache.get(id).name || 'Individual Work'; } From 0ba137e80bef1dd0ff4d6fee6d0d0173f0b81cca Mon Sep 17 00:00:00 2001 From: b0ink <40929320+b0ink@users.noreply.github.com> Date: Mon, 13 Apr 2026 18:45:20 +1000 Subject: [PATCH 21/58] chore(release): 10.0.27 --- CHANGELOG.md | 7 +++++++ package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f6d6fd1f9..e34187d975 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ 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.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) diff --git a/package-lock.json b/package-lock.json index 4a302d94a6..e71a7884d4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "doubtfire", - "version": "10.0.1-26", + "version": "10.0.27", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "doubtfire", - "version": "10.0.1-26", + "version": "10.0.27", "license": "AGPL-3.0", "dependencies": { "@angular/animations": "^17.3.6", diff --git a/package.json b/package.json index 03f925ec90..08eebbde57 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "doubtfire", - "version": "10.0.1-26", + "version": "10.0.27", "homepage": "http://github.com/doubtfire-lms/", "description": "Learning and feedback tool.", "license": "AGPL-3.0", From b88520813f1c654cfa5c50647c32d9385e3895b2 Mon Sep 17 00:00:00 2001 From: b0ink <40929320+b0ink@users.noreply.github.com> Date: Mon, 13 Apr 2026 19:00:22 +1000 Subject: [PATCH 22/58] chore(release): 10.0.1-27 --- CHANGELOG.md | 2 ++ package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e34187d975..8653fd3bbb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ 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-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) diff --git a/package-lock.json b/package-lock.json index e71a7884d4..d4a3cf34f1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "doubtfire", - "version": "10.0.27", + "version": "10.0.1-27", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "doubtfire", - "version": "10.0.27", + "version": "10.0.1-27", "license": "AGPL-3.0", "dependencies": { "@angular/animations": "^17.3.6", diff --git a/package.json b/package.json index 08eebbde57..9739461127 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "doubtfire", - "version": "10.0.27", + "version": "10.0.1-27", "homepage": "http://github.com/doubtfire-lms/", "description": "Learning and feedback tool.", "license": "AGPL-3.0", From f35d19df7b7b72df62b14df214cfcecc73041a3a Mon Sep 17 00:00:00 2001 From: b0ink <40929320+b0ink@users.noreply.github.com> Date: Mon, 13 Apr 2026 19:16:53 +1000 Subject: [PATCH 23/58] chore: revert package upgrade --- package-lock.json | 9 +++++---- package.json | 2 +- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index d4a3cf34f1..24b32f95ec 100644 --- a/package-lock.json +++ b/package-lock.json @@ -40,7 +40,7 @@ "angular-mocks": "1.8.3", "angular-nvd3": "1.0.9", "angular-resource": "1.5.11", - "angular-sanitize": "1.8.3", + "angular-sanitize": "1.5.11", "angular-ui-bootstrap": "0.13.4", "angular-ui-codemirror": "0.3.0", "angular-xeditable": "0.9.0", @@ -6615,9 +6615,9 @@ "license": "MIT" }, "node_modules/angular-sanitize": { - "version": "1.8.3", - "resolved": "https://registry.npmjs.org/angular-sanitize/-/angular-sanitize-1.8.3.tgz", - "integrity": "sha512-2rxdqzlUVafUeWOwvY/FtyWk1pFTyCtzreeiTytG9m4smpuAEKaIJAjYeVwWsoV+nlTOcgpwV4W1OCmR+BQbUg==", + "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" }, @@ -11213,6 +11213,7 @@ "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": [ diff --git a/package.json b/package.json index 9739461127..1f4ee0d726 100644 --- a/package.json +++ b/package.json @@ -59,7 +59,7 @@ "angular-mocks": "1.8.3", "angular-nvd3": "1.0.9", "angular-resource": "1.5.11", - "angular-sanitize": "1.8.3", + "angular-sanitize": "1.5.11", "angular-ui-bootstrap": "0.13.4", "angular-ui-codemirror": "0.3.0", "angular-xeditable": "0.9.0", From c47ded6d7b1c355e7c2efad7b9cb091bc0753241 Mon Sep 17 00:00:00 2001 From: b0ink <40929320+b0ink@users.noreply.github.com> Date: Mon, 13 Apr 2026 19:17:00 +1000 Subject: [PATCH 24/58] chore(release): 10.0.1-28 --- CHANGELOG.md | 2 ++ package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8653fd3bbb..baf2673105 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ 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-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) diff --git a/package-lock.json b/package-lock.json index 24b32f95ec..5aa7702c57 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "doubtfire", - "version": "10.0.1-27", + "version": "10.0.1-28", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "doubtfire", - "version": "10.0.1-27", + "version": "10.0.1-28", "license": "AGPL-3.0", "dependencies": { "@angular/animations": "^17.3.6", diff --git a/package.json b/package.json index 1f4ee0d726..2c66fc696e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "doubtfire", - "version": "10.0.1-27", + "version": "10.0.1-28", "homepage": "http://github.com/doubtfire-lms/", "description": "Learning and feedback tool.", "license": "AGPL-3.0", From 4d8bb5b82d89caa02ed4936819be601b3f6977fc Mon Sep 17 00:00:00 2001 From: Boink <40929320+b0ink@users.noreply.github.com> Date: Wed, 15 Apr 2026 10:38:53 +0900 Subject: [PATCH 25/58] feat: discussed in class refactor (#1145) * feat: init discussed in class refactor * chore: ensure unit can be saved * refactor: hide ready for feedback tasks * chore: remove discussed blocks * chore: always require prompt for discussed in class * refactor: require feedback for complete status changes --- .../api/models/task-comment/task-comment.ts | 18 +++ src/app/api/models/task.ts | 120 ++++++++++++++---- src/app/api/models/unit.ts | 1 + src/app/api/services/unit.service.ts | 2 + src/app/common/footer/footer.component.html | 2 +- src/app/common/footer/footer.component.ts | 30 +++++ ...ussed-in-class-reason-modal.component.html | 33 +++++ ...ussed-in-class-reason-modal.component.scss | 7 + ...scussed-in-class-reason-modal.component.ts | 47 +++++++ ...discussed-in-class-reason-modal.service.ts | 35 +++++ src/app/doubtfire-angular.module.ts | 2 + .../tutor-discussion.component.ts | 58 +++++++-- .../unit-details-editor.component.html | 10 ++ 13 files changed, 328 insertions(+), 37 deletions(-) create mode 100644 src/app/common/modals/discussed-in-class-reason-modal/discussed-in-class-reason-modal.component.html create mode 100644 src/app/common/modals/discussed-in-class-reason-modal/discussed-in-class-reason-modal.component.scss create mode 100644 src/app/common/modals/discussed-in-class-reason-modal/discussed-in-class-reason-modal.component.ts create mode 100644 src/app/common/modals/discussed-in-class-reason-modal/discussed-in-class-reason-modal.service.ts diff --git a/src/app/api/models/task-comment/task-comment.ts b/src/app/api/models/task-comment/task-comment.ts index 5d49c9ef9f..e9b9bb55ce 100644 --- a/src/app/api/models/task-comment/task-comment.ts +++ b/src/app/api/models/task-comment/task-comment.ts @@ -57,6 +57,24 @@ 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; } diff --git a/src/app/api/models/task.ts b/src/app/api/models/task.ts index 26488452d3..4b530ffea1 100644 --- a/src/app/api/models/task.ts +++ b/src/app/api/models/task.ts @@ -20,7 +20,9 @@ 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'; @@ -126,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, @@ -147,6 +153,18 @@ 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; @@ -891,38 +909,74 @@ 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( + public async updateTaskStatus( status: TaskStatusEnum, markAsDiscussed?: boolean, triggerRecursiveFix?: boolean, @@ -935,6 +989,18 @@ export class Task extends Entity { return; } + if (status === 'complete' || status === 'fix_and_resubmit') { + if (!this.commentsSinceLatestReadyForFeedback().some((comment) => comment.isManualFeedback)) { + alerts.error( + status === 'complete' + ? 'Feedback must be given before moving this task to Complete' + : 'Feedback must be given before moving this task to Fix and Resubmit', + 6000, + ); + return; + } + } + const updateFunc = () => { const taskService: TaskService = AppInjector.get(TaskService); const options: RequestOptions = { @@ -1008,7 +1074,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); @@ -1021,7 +1087,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); } } diff --git a/src/app/api/models/unit.ts b/src/app/api/models/unit.ts index e9f48f02f9..cd10810ac2 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; 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 0d960262ad..50233659f3 100644 --- a/src/app/common/footer/footer.component.html +++ b/src/app/common/footer/footer.component.html @@ -294,7 +294,7 @@ - diff --git a/src/app/common/footer/footer.component.ts b/src/app/common/footer/footer.component.ts index aab80e6fd5..1b2381a977 100644 --- a/src/app/common/footer/footer.component.ts +++ b/src/app/common/footer/footer.component.ts @@ -9,6 +9,7 @@ import {UnitRole} from 'src/app/api/models/unit-role'; import {UserService} from 'src/app/api/services/user.service'; import {ProjectService} from 'src/app/api/services/project.service'; import {ConfirmationModalService} from '../modals/confirmation-modal/confirmation-modal.service'; +import {DiscussedInClassReasonModalService} from '../modals/discussed-in-class-reason-modal/discussed-in-class-reason-modal.service'; import {AlertService} from '../services/alert.service'; @Component({ @@ -17,6 +18,8 @@ import {AlertService} from '../services/alert.service'; styleUrls: ['./footer.component.scss'], }) export class FooterComponent implements OnInit { + private readonly discussedInClassNotePrefix = `I'm manually marking this discussed in class because...`; + constructor( public selectedTaskService: SelectedTaskService, public taskService: TaskService, @@ -25,6 +28,7 @@ export class FooterComponent implements OnInit { private userService: UserService, private projectService: ProjectService, private confirmationModalService: ConfirmationModalService, + private discussedInClassReasonModal: DiscussedInClassReasonModalService, private alertService: AlertService, ) {} @@ -227,4 +231,30 @@ export class FooterComponent implements OnInit { task.updateTaskStatus('fix_and_resubmit'); } } + + public markSelectedTaskAsDiscussed() { + if (!this.selectedTask) { + return; + } + + if (!this.selectedTask.unit.enforceFeedbackBeforeDiscussedInClass) { + this.selectedTask.markAsDiscussed(); + return; + } + + this.discussedInClassReasonModal + .show( + 'Mark Discussed in Class', + `Add a tutor note explaining why ${this.selectedTask.definition.abbreviation} is being marked as discussed in class.`, + this.discussedInClassNotePrefix, + ) + .afterClosed() + .subscribe((reason) => { + if (!reason) { + return; + } + + this.selectedTask.markAsDiscussed(reason); + }); + } } 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/doubtfire-angular.module.ts b/src/app/doubtfire-angular.module.ts index ae030bcc3e..879db426d0 100644 --- a/src/app/doubtfire-angular.module.ts +++ b/src/app/doubtfire-angular.module.ts @@ -103,6 +103,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 { @@ -388,6 +389,7 @@ const GANTT_CHART_CONFIG = { SpecConModalComponent, CalendarModalComponent, ConfirmationModalComponent, + DiscussedInClassReasonModalComponent, InstitutionSettingsComponent, ProjectPlanComponent, SuccessCloseComponent, 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 84095a5436..c9e43c890a 100644 --- a/src/app/projects/states/tutor-discussion/tutor-discussion.component.ts +++ b/src/app/projects/states/tutor-discussion/tutor-discussion.component.ts @@ -18,6 +18,7 @@ import { 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'; @@ -33,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; @@ -68,6 +71,7 @@ export class TutorDiscussionComponent implements AfterViewInit { private state: StateService, private alertService: AlertService, private confirmationModalService: ConfirmationModalService, + private discussedInClassReasonModal: DiscussedInClassReasonModalService, private route: UIRouter, private taskCommentService: TaskCommentService, private taskService: TaskService, @@ -352,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() { @@ -439,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]; } @@ -461,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/units/states/edit/directives/unit-details-editor/unit-details-editor.component.html b/src/app/units/states/edit/directives/unit-details-editor/unit-details-editor.component.html index 073c69ce2a..60c4e1e260 100644 --- a/src/app/units/states/edit/directives/unit-details-editor/unit-details-editor.component.html +++ b/src/app/units/states/edit/directives/unit-details-editor/unit-details-editor.component.html @@ -169,6 +169,16 @@

Unit Details

When false only staff can change student tutorials.

+
+ Require feedback before discussed in class +

+ When true, tutors must record feedback before they can mark discussion tasks as discussed in + class. +

+
+
Send notification emails Date: Wed, 15 Apr 2026 11:39:54 +1000 Subject: [PATCH 26/58] chore(release): 10.0.1-29 --- CHANGELOG.md | 7 +++++++ package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index baf2673105..fd5b782d47 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ 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-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) diff --git a/package-lock.json b/package-lock.json index 5aa7702c57..ab77b28b2c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "doubtfire", - "version": "10.0.1-28", + "version": "10.0.1-29", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "doubtfire", - "version": "10.0.1-28", + "version": "10.0.1-29", "license": "AGPL-3.0", "dependencies": { "@angular/animations": "^17.3.6", diff --git a/package.json b/package.json index 2c66fc696e..2836cfaea9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "doubtfire", - "version": "10.0.1-28", + "version": "10.0.1-29", "homepage": "http://github.com/doubtfire-lms/", "description": "Learning and feedback tool.", "license": "AGPL-3.0", From 9053dfa0d36fe4d9bfe97328acb63f1371fb7549 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 15 Apr 2026 01:41:27 +0000 Subject: [PATCH 27/58] build(deps-dev): bump follow-redirects from 1.15.11 to 1.16.0 Bumps [follow-redirects](https://github.com/follow-redirects/follow-redirects) from 1.15.11 to 1.16.0. - [Release notes](https://github.com/follow-redirects/follow-redirects/releases) - [Commits](https://github.com/follow-redirects/follow-redirects/compare/v1.15.11...v1.16.0) --- updated-dependencies: - dependency-name: follow-redirects dependency-version: 1.16.0 dependency-type: indirect ... Signed-off-by: dependabot[bot] --- package-lock.json | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index ab77b28b2c..3af8fa7ccc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11042,9 +11042,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": [ { @@ -11213,7 +11213,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": [ From dfcfd472305ef2971fa72734c18e77253b98fa11 Mon Sep 17 00:00:00 2001 From: b0ink <40929320+b0ink@users.noreply.github.com> Date: Wed, 15 Apr 2026 12:08:38 +1000 Subject: [PATCH 28/58] feat: display icon for tasks escalated by student --- .../staff-task-list/staff-task-list.component.html | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) 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 2f96b676bb..fc1b137281 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 @@ -259,7 +259,14 @@
-

+

+ @if (task.moderationType === 'escalation') { + gavel + } @if (getWarningIcon(task) === 'warning') { } - {{ task.project.student.name }} + {{ task.project.student.name }}

{{ task.definition.abbreviation }} - From 48441192acce0a32726d7db5f1efb698ae27898f Mon Sep 17 00:00:00 2001 From: b0ink <40929320+b0ink@users.noreply.github.com> Date: Wed, 15 Apr 2026 12:29:23 +1000 Subject: [PATCH 29/58] refactor: add href to user badge --- .../user-badge/user-badge.component.html | 18 +++++++++-- .../common/user-badge/user-badge.component.ts | 30 +++++++++++++++++-- 2 files changed, 42 insertions(+), 6 deletions(-) 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, From 9fd11755f3264b6b7247f2f0bcb8d21692fc006a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 15 Apr 2026 02:42:21 +0000 Subject: [PATCH 30/58] build(deps): bump jquery from 2.1.4 to 3.5.0 Bumps [jquery](https://github.com/jquery/jquery) from 2.1.4 to 3.5.0. - [Release notes](https://github.com/jquery/jquery/releases) - [Changelog](https://github.com/jquery/jquery/blob/main/changelog.md) - [Commits](https://github.com/jquery/jquery/compare/2.1.4...3.5.0) --- updated-dependencies: - dependency-name: jquery dependency-version: 3.5.0 dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- package-lock.json | 7 +++++-- package.json | 2 +- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 5e16479f53..227c94a1f0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -58,7 +58,7 @@ "font-awesome": "~4.7.0", "html2canvas": "^1.4.1", "html5-qrcode": "^2.3.8", - "jquery": "2.1.4", + "jquery": "3.5.0", "jszip": "^3.10.1", "lodash": "~4.18", "lottie-web": "^5.13.0", @@ -14519,7 +14519,10 @@ } }, "node_modules/jquery": { - "version": "2.1.4" + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/jquery/-/jquery-3.5.0.tgz", + "integrity": "sha512-Xb7SVYMvygPxbFMpTFQiHh1J7HClEaThguL15N/Gg37Lri/qKyhRGZYzHRyLH8Stq3Aow0LsHO2O2ci86fCrNQ==", + "license": "MIT" }, "node_modules/js-base64": { "version": "2.6.4", diff --git a/package.json b/package.json index 3b58d7c3ef..c4d0de4452 100644 --- a/package.json +++ b/package.json @@ -77,7 +77,7 @@ "font-awesome": "~4.7.0", "html2canvas": "^1.4.1", "html5-qrcode": "^2.3.8", - "jquery": "2.1.4", + "jquery": "3.5.0", "jszip": "^3.10.1", "lodash": "~4.18", "lottie-web": "^5.13.0", From 80f92e2277c48978e1738340063a7223c04fddb8 Mon Sep 17 00:00:00 2001 From: b0ink <40929320+b0ink@users.noreply.github.com> Date: Wed, 15 Apr 2026 15:05:14 +1000 Subject: [PATCH 31/58] fix: debounce duplicate task submission requests --- .../upload-submission-modal.coffee | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) 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') From 1647e2bcc86ac6fb08c82be9795a06939c57bb6e Mon Sep 17 00:00:00 2001 From: b0ink <40929320+b0ink@users.noreply.github.com> Date: Thu, 16 Apr 2026 15:24:21 +1000 Subject: [PATCH 32/58] feat: enable task pinning in explorer --- src/app/api/models/task.ts | 6 +- .../staff-task-list.component.html | 66 +++++++++---------- .../staff-task-list.component.ts | 17 ++++- 3 files changed, 52 insertions(+), 37 deletions(-) diff --git a/src/app/api/models/task.ts b/src/app/api/models/task.ts index 4b530ffea1..a6e46b4225 100644 --- a/src/app/api/models/task.ts +++ b/src/app/api/models/task.ts @@ -1109,12 +1109,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); @@ -1122,7 +1123,7 @@ export class Task extends Entity { }); } - public unpin(): void { + public unpin(onSuccess?: () => void): void { const http = AppInjector.get(HttpClient); http @@ -1130,6 +1131,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/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 2f96b676bb..6b4140a092 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 @@ -311,42 +311,40 @@

[status]="task.status" class="ml-3" > - @if (!isTaskDefMode) { -
- - + + + - - - -
- } + +

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 3b65b4247e..5cad111745 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 @@ -359,6 +359,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 +564,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 { @@ -584,4 +591,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)); + } } From 9f959877b6f47d878d87b468f4aab73f536d598b Mon Sep 17 00:00:00 2001 From: Boink <40929320+b0ink@users.noreply.github.com> Date: Sat, 18 Apr 2026 16:10:07 +1000 Subject: [PATCH 33/58] feat: batch upload feedback csv (#1175) * feat: batch upload feedback csv * refactor: reword csv to zip * refactor: improve ui layout of dialog * chore: clarify marks csv columns --- src/app/api/models/unit.ts | 10 ++++ src/app/doubtfire-angular.module.ts | 2 + ...ch-feedback-workflow-dialog.component.html | 49 +++++++++++++++++++ ...atch-feedback-workflow-dialog.component.ts | 44 +++++++++++++++++ .../staff-task-list.component.html | 6 +++ .../staff-task-list.component.ts | 48 ++++++++++++++++++ 6 files changed, 159 insertions(+) create mode 100644 src/app/units/states/tasks/inbox/directives/staff-task-list/batch-feedback-workflow-dialog/batch-feedback-workflow-dialog.component.html create mode 100644 src/app/units/states/tasks/inbox/directives/staff-task-list/batch-feedback-workflow-dialog/batch-feedback-workflow-dialog.component.ts diff --git a/src/app/api/models/unit.ts b/src/app/api/models/unit.ts index cd10810ac2..e30181ddab 100644 --- a/src/app/api/models/unit.ts +++ b/src/app/api/models/unit.ts @@ -586,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/doubtfire-angular.module.ts b/src/app/doubtfire-angular.module.ts index 879db426d0..daa4be1ca2 100644 --- a/src/app/doubtfire-angular.module.ts +++ b/src/app/doubtfire-angular.module.ts @@ -149,6 +149,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'; @@ -427,6 +428,7 @@ const GANTT_CHART_CONFIG = { SafePipe, PdfViewerPanelComponent, StaffTaskListComponent, + BatchFeedbackWorkflowDialogComponent, TaskSimilarityViewComponent, FiltersPipe, TasksOfTaskDefinitionPipe, 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..27a6866931 --- /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,49 @@ +

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 files that match the task upload requirements. +

+

+ @if (uploadRequirementNames.length > 0) { + @for (requirementName of uploadRequirementNames; track requirementName; let last = $last) { + {{ requirementName }} + @if (!last) { + | + } + } + } @else { + none listed for this task. + } +

+
+ +
+

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..c911874b17 --- /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,44 @@ +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}`; + } + + get uploadRequirementNames(): string[] { + return ( + this.data.taskDefinition?.uploadRequirements + ?.map((requirement) => requirement.name) + .filter((name): name is string => !!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 e815f0cb7a..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/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..bb218ef35a 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 @@ -158,6 +158,8 @@ [attr.contenteditable]="contentEditableValue() && !recording" (keydown.enter)="send($event)" (keydown)="keyTyped()" + (beforeinput)="handleBeforeInput($event)" + (paste)="handlePaste($event)" (input)="onInputChange($event)" placeholder="Aa" name="commentComposer" 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..8f748ae4db 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; @@ -550,18 +551,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,6 +644,34 @@ 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(); From d938712843510ab4b4fa2861d13be3d3546cdf71 Mon Sep 17 00:00:00 2001 From: b0ink <40929320+b0ink@users.noreply.github.com> Date: Wed, 22 Apr 2026 13:08:38 +1000 Subject: [PATCH 42/58] chore: clear overseer step cache --- src/app/api/services/task-definition.service.ts | 1 + 1 file changed, 1 insertion(+) 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'], From 091aaf8ba744bbf677f9b8f51a58bc16b7d631ab Mon Sep 17 00:00:00 2001 From: b0ink <40929320+b0ink@users.noreply.github.com> Date: Thu, 23 Apr 2026 12:26:00 +1000 Subject: [PATCH 43/58] fix: open report in turnitin --- .../task-similarity-view.component.html | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) 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') { From 01b5b2f54039b226db5b1a8079052495d9181fa1 Mon Sep 17 00:00:00 2001 From: b0ink <40929320+b0ink@users.noreply.github.com> Date: Thu, 23 Apr 2026 14:26:17 +1000 Subject: [PATCH 44/58] chore(release): 10.0.1-33 --- CHANGELOG.md | 12 ++++++++++++ package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 479b4b0f32..f757dcbd9d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,18 @@ 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-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) diff --git a/package-lock.json b/package-lock.json index 7a076685c6..5390f0565a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "doubtfire", - "version": "10.0.1-32", + "version": "10.0.1-33", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "doubtfire", - "version": "10.0.1-32", + "version": "10.0.1-33", "license": "AGPL-3.0", "dependencies": { "@angular/animations": "^17.3.6", diff --git a/package.json b/package.json index f98763efd4..5d64a8c6ab 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "doubtfire", - "version": "10.0.1-32", + "version": "10.0.1-33", "homepage": "http://github.com/doubtfire-lms/", "description": "Learning and feedback tool.", "license": "AGPL-3.0", From 2efd5cfe741999035e4537acacfe012c05c8558a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 23 Apr 2026 04:42:44 +0000 Subject: [PATCH 45/58] build(deps): bump tslib from 2.6.2 to 2.8.1 Bumps [tslib](https://github.com/Microsoft/tslib) from 2.6.2 to 2.8.1. - [Release notes](https://github.com/Microsoft/tslib/releases) - [Commits](https://github.com/Microsoft/tslib/compare/v2.6.2...v2.8.1) --- updated-dependencies: - dependency-name: tslib dependency-version: 2.8.1 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- package-lock.json | 14 +++++++++++--- package.json | 2 +- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index 5390f0565a..f4d83b33a6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,7 +26,6 @@ "@ctrl/ngx-emoji-mart": "^9.3.0", "@ngneat/hotkeys": "^4.0.0", "@ngstack/code-editor": "7.3.0", - "@rollup/rollup-linux-arm64-gnu": "4.60.2", "@uirouter/angular": "^13.0", "@uirouter/angular-hybrid": "^17.1.0", "@uirouter/angularjs": "^1.0.30", @@ -78,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" }, @@ -428,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, @@ -21973,7 +21979,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 5d64a8c6ab..ee22562de7 100644 --- a/package.json +++ b/package.json @@ -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" }, From 8e9616a0d27f8f55a9c939fd451aab1c6630a5b6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 24 Apr 2026 15:33:26 +0000 Subject: [PATCH 46/58] build(deps-dev): bump postcss from 8.4.38 to 8.5.10 Bumps [postcss](https://github.com/postcss/postcss) from 8.4.38 to 8.5.10. - [Release notes](https://github.com/postcss/postcss/releases) - [Changelog](https://github.com/postcss/postcss/blob/main/CHANGELOG.md) - [Commits](https://github.com/postcss/postcss/compare/8.4.38...8.5.10) --- updated-dependencies: - dependency-name: postcss dependency-version: 8.5.10 dependency-type: direct:development ... Signed-off-by: dependabot[bot] --- package-lock.json | 21 +++++++++++++-------- package.json | 2 +- 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/package-lock.json b/package-lock.json index 5390f0565a..57349e6685 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,7 +26,6 @@ "@ctrl/ngx-emoji-mart": "^9.3.0", "@ngneat/hotkeys": "^4.0.0", "@ngstack/code-editor": "7.3.0", - "@rollup/rollup-linux-arm64-gnu": "4.60.2", "@uirouter/angular": "^13.0", "@uirouter/angular-hybrid": "^17.1.0", "@uirouter/angularjs": "^1.0.30", @@ -142,7 +141,7 @@ "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", "protractor": "~7.0.0", @@ -16164,7 +16163,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": [ { @@ -18095,7 +18096,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": [ { @@ -18113,9 +18116,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" @@ -20847,7 +20850,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": { diff --git a/package.json b/package.json index 5d64a8c6ab..2423c7d006 100644 --- a/package.json +++ b/package.json @@ -160,7 +160,7 @@ "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", "protractor": "~7.0.0", From e9f7e39805998580050c8a7d726112deba78e777 Mon Sep 17 00:00:00 2001 From: b0ink <40929320+b0ink@users.noreply.github.com> Date: Sat, 25 Apr 2026 13:40:04 +1000 Subject: [PATCH 47/58] chore: increase scrolling attempt to find comparison --- src/app/projects/states/jplag/jplag-report-viewer.component.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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); } From b7939fa37c60e924b6df30168ee0e9d46da78a98 Mon Sep 17 00:00:00 2001 From: b0ink <40929320+b0ink@users.noreply.github.com> Date: Mon, 27 Apr 2026 10:07:11 +1000 Subject: [PATCH 48/58] chore(release): 10.0.1-34 --- CHANGELOG.md | 2 ++ package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f757dcbd9d..1f21244c3b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ 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-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) diff --git a/package-lock.json b/package-lock.json index 5390f0565a..d3d4e71c92 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "doubtfire", - "version": "10.0.1-33", + "version": "10.0.1-34", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "doubtfire", - "version": "10.0.1-33", + "version": "10.0.1-34", "license": "AGPL-3.0", "dependencies": { "@angular/animations": "^17.3.6", diff --git a/package.json b/package.json index 5d64a8c6ab..87ecd52c4d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "doubtfire", - "version": "10.0.1-33", + "version": "10.0.1-34", "homepage": "http://github.com/doubtfire-lms/", "description": "Learning and feedback tool.", "license": "AGPL-3.0", From 63c52dc4cdb2f2fbc701f69c64b7273f5778c868 Mon Sep 17 00:00:00 2001 From: b0ink <40929320+b0ink@users.noreply.github.com> Date: Mon, 27 Apr 2026 13:10:14 +1000 Subject: [PATCH 49/58] fix: task route transition race when switching from inbox --- src/app/units/states/tasks/tasks.coffee | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) 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 From c91ab478ae1d1cd459040290f6f7a44c7dce3efe Mon Sep 17 00:00:00 2001 From: Boink <40929320+b0ink@users.noreply.github.com> Date: Mon, 27 Apr 2026 16:24:10 +1000 Subject: [PATCH 50/58] feat: bulk import staff via emails (#1195) --- src/app/doubtfire-angular.module.ts | 2 + .../bulk-import-staff-modal.component.html | 22 +++ .../bulk-import-staff-modal.component.ts | 27 ++++ .../bulk-import-staff-modal.service.ts | 21 +++ .../unit-staff-editor.component.html | 46 +++--- .../unit-staff-editor.component.ts | 141 +++++++++++++++++- 6 files changed, 241 insertions(+), 18 deletions(-) create mode 100644 src/app/units/states/edit/directives/unit-staff-editor/bulk-import-staff-modal/bulk-import-staff-modal.component.html create mode 100644 src/app/units/states/edit/directives/unit-staff-editor/bulk-import-staff-modal/bulk-import-staff-modal.component.ts create mode 100644 src/app/units/states/edit/directives/unit-staff-editor/bulk-import-staff-modal/bulk-import-staff-modal.service.ts diff --git a/src/app/doubtfire-angular.module.ts b/src/app/doubtfire-angular.module.ts index fd77e17b3b..e694c419fc 100644 --- a/src/app/doubtfire-angular.module.ts +++ b/src/app/doubtfire-angular.module.ts @@ -326,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 = { @@ -504,6 +505,7 @@ const GANTT_CHART_CONFIG = { TaskDefinitionPrerequisitesComponent, TaskPrerequisitesCardComponent, UnitStaffEditorComponent, + BulkImportStaffModalComponent, GroupSetSelectorComponent, UnitDetailsEditorComponent, PortfolioGradeSelectStepComponent, diff --git a/src/app/units/states/edit/directives/unit-staff-editor/bulk-import-staff-modal/bulk-import-staff-modal.component.html b/src/app/units/states/edit/directives/unit-staff-editor/bulk-import-staff-modal/bulk-import-staff-modal.component.html new file mode 100644 index 0000000000..32c44da5da --- /dev/null +++ b/src/app/units/states/edit/directives/unit-staff-editor/bulk-import-staff-modal/bulk-import-staff-modal.component.html @@ -0,0 +1,22 @@ +

Bulk Import Staff

+ +
+

Paste one staff email per line to add them to this unit as tutors.

+ + + Staff emails + + +
+ +
+ + +
diff --git a/src/app/units/states/edit/directives/unit-staff-editor/bulk-import-staff-modal/bulk-import-staff-modal.component.ts b/src/app/units/states/edit/directives/unit-staff-editor/bulk-import-staff-modal/bulk-import-staff-modal.component.ts new file mode 100644 index 0000000000..6a420e3986 --- /dev/null +++ b/src/app/units/states/edit/directives/unit-staff-editor/bulk-import-staff-modal/bulk-import-staff-modal.component.ts @@ -0,0 +1,27 @@ +import {Component} from '@angular/core'; +import {MatDialogRef} from '@angular/material/dialog'; + +@Component({ + selector: 'bulk-import-staff-modal', + templateUrl: './bulk-import-staff-modal.component.html', +}) +export class BulkImportStaffModalComponent { + public emailList = ''; + + constructor( + public dialogRef: MatDialogRef, + ) {} + + public cancel(): void { + this.dialogRef.close(undefined); + } + + public submit(): void { + const trimmedEmails = this.emailList.trim(); + if (!trimmedEmails) { + return; + } + + this.dialogRef.close(trimmedEmails); + } +} diff --git a/src/app/units/states/edit/directives/unit-staff-editor/bulk-import-staff-modal/bulk-import-staff-modal.service.ts b/src/app/units/states/edit/directives/unit-staff-editor/bulk-import-staff-modal/bulk-import-staff-modal.service.ts new file mode 100644 index 0000000000..816a659ad8 --- /dev/null +++ b/src/app/units/states/edit/directives/unit-staff-editor/bulk-import-staff-modal/bulk-import-staff-modal.service.ts @@ -0,0 +1,21 @@ +import {Injectable} from '@angular/core'; +import {MatDialog, MatDialogRef} from '@angular/material/dialog'; +import {BulkImportStaffModalComponent} from './bulk-import-staff-modal.component'; + +@Injectable({ + providedIn: 'root', +}) +export class BulkImportStaffModalService { + constructor(private dialog: MatDialog) {} + + public show(): MatDialogRef { + return this.dialog.open( + BulkImportStaffModalComponent, + { + position: {top: '2.5%'}, + width: '100%', + maxWidth: '700px', + }, + ); + } +} diff --git a/src/app/units/states/edit/directives/unit-staff-editor/unit-staff-editor.component.html b/src/app/units/states/edit/directives/unit-staff-editor/unit-staff-editor.component.html index c95e5c847e..210e1be655 100644 --- a/src/app/units/states/edit/directives/unit-staff-editor/unit-staff-editor.component.html +++ b/src/app/units/states/edit/directives/unit-staff-editor/unit-staff-editor.component.html @@ -118,22 +118,34 @@

Unit Staff

- - - + + + + + {{ staff.name }} + + + + + + diff --git a/src/app/units/states/edit/directives/unit-staff-editor/unit-staff-editor.component.ts b/src/app/units/states/edit/directives/unit-staff-editor/unit-staff-editor.component.ts index d481e6cb49..895a4c03d7 100644 --- a/src/app/units/states/edit/directives/unit-staff-editor/unit-staff-editor.component.ts +++ b/src/app/units/states/edit/directives/unit-staff-editor/unit-staff-editor.component.ts @@ -1,4 +1,4 @@ -import {Component, Input, OnInit} from '@angular/core'; +import {Component, Inject, Input, OnInit} from '@angular/core'; import {AlertService} from 'src/app/common/services/alert.service'; import {UnitRoleService} from 'src/app/api/services/unit-role.service'; import {Unit} from 'src/app/api/models/unit'; @@ -10,6 +10,23 @@ import {ConfirmationModalService} from 'src/app/common/modals/confirmation-modal import {MatSelectChange} from '@angular/material/select'; import {TutorNotesModalService} from 'src/app/common/modals/tutor-notes-modal/tutor-notes-modal.service'; import {UserService} from 'src/app/api/services/user.service'; +import {BulkImportStaffModalService} from './bulk-import-staff-modal/bulk-import-staff-modal.service'; +import {csvResultModalService} from 'src/app/ajs-upgraded-providers'; + +interface CsvResultRow { + row: string; + message: string; +} + +interface CsvResultResponse { + success: CsvResultRow[]; + errors: CsvResultRow[]; + ignored: CsvResultRow[]; +} + +interface CsvResultModal { + show(title: string, response: CsvResultResponse): void; +} @Component({ selector: 'unit-staff-editor', @@ -43,6 +60,8 @@ export class UnitStaffEditorComponent implements OnInit { private userService: UserService, private confirmationModalService: ConfirmationModalService, private tutorNotesModal: TutorNotesModalService, + private bulkImportStaffModal: BulkImportStaffModalService, + @Inject(csvResultModalService) private csvResultModal: CsvResultModal, ) {} ngOnInit(): void { @@ -172,6 +191,19 @@ export class UnitStaffEditorComponent implements OnInit { } } + openBulkImportModal() { + this.bulkImportStaffModal + .show() + .afterClosed() + .subscribe((emailList) => { + if (!emailList) { + return; + } + + this.bulkImportStaffFromEmailList(emailList); + }); + } + /** * Used in filtering the staff list. The `searchTerm` is bound to the auto-complete input in this class's template. * @@ -294,4 +326,111 @@ export class UnitStaffEditorComponent implements OnInit { unitRole.unit = this.unit; // HACK: ensure unit is mapped within the UnitRole this.tutorNotesModal.show(null, unitRole); } + + private bulkImportStaffFromEmailList(emailList: string): void { + const parsedEmails = this.parseEmailList(emailList); + + if (parsedEmails.length === 0) { + this.alertService.error('Please enter at least one valid email address.', 6000); + return; + } + + const existingStaffEmails = new Set( + this.unit.staff + .map((unitRole) => unitRole.user.email?.trim().toLowerCase()) + .filter((email): email is string => !!email), + ); + const staffByEmail = new Map( + this.staff + .filter((staff) => staff.isStaff && staff.email) + .map((staff) => [staff.email.trim().toLowerCase(), staff] as const), + ); + + const alreadyAssignedEmails = parsedEmails.filter((email) => existingStaffEmails.has(email)); + const matchedUsers = parsedEmails + .filter((email) => !existingStaffEmails.has(email)) + .map((email) => staffByEmail.get(email)) + .filter((staff): staff is User => !!staff); + const unmatchedEmails = parsedEmails.filter( + (email) => !existingStaffEmails.has(email) && !staffByEmail.has(email), + ); + const ignoredRows = alreadyAssignedEmails.map((email) => + this.csvResultRow(email, 'Staff member is already assigned to this unit'), + ); + const unmatchedRows = unmatchedEmails.map((email) => + this.csvResultRow(email, 'No matching staff user was found'), + ); + + if (matchedUsers.length === 0) { + this.csvResultModal.show( + 'Bulk staff import results', + this.csvResultResponse([], unmatchedRows, ignoredRows), + ); + return; + } + + this.addStaffUsersSequentially(matchedUsers, [], [], ({addedEmails, failedEmails}) => { + const successRows = addedEmails.map((email) => + this.csvResultRow(email, 'Staff member added'), + ); + const failedRows = failedEmails.map((email) => + this.csvResultRow(email, 'Could not add staff member to this unit'), + ); + + this.csvResultModal.show( + 'Bulk staff import results', + this.csvResultResponse(successRows, [...unmatchedRows, ...failedRows], ignoredRows), + ); + }); + } + + private addStaffUsersSequentially( + users: User[], + addedEmails: string[], + failedEmails: string[], + onComplete: (result: {addedEmails: string[]; failedEmails: string[]}) => void, + ): void { + if (users.length === 0) { + onComplete({addedEmails, failedEmails}); + return; + } + + const [nextUser, ...remainingUsers] = users; + + this.unit.addStaff(nextUser).subscribe({ + next: () => { + addedEmails.push(nextUser.email); + this.addStaffUsersSequentially(remainingUsers, addedEmails, failedEmails, onComplete); + }, + error: () => { + failedEmails.push(nextUser.email); + this.addStaffUsersSequentially(remainingUsers, addedEmails, failedEmails, onComplete); + }, + }); + } + + private parseEmailList(emailList: string): string[] { + const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + + return Array.from( + new Set( + emailList + .split(/\r?\n/) + .map((email) => email.trim().toLowerCase()) + .filter((email) => emailPattern.test(email)), + ), + ); + } + + private csvResultRow(row: string, message: string): CsvResultRow { + return {row, message}; + } + + private csvResultResponse( + success: CsvResultRow[], + errors: CsvResultRow[], + ignored: CsvResultRow[], + ): CsvResultResponse { + return {success, errors, ignored}; + } } From 976b6ac4fa3bd2d5e954eebcae3fcb40dd8d1f0e Mon Sep 17 00:00:00 2001 From: Boink <40929320+b0ink@users.noreply.github.com> Date: Tue, 28 Apr 2026 11:42:10 +1000 Subject: [PATCH 51/58] feat: edit comments (#1194) * feat: enable comment editing * chore: fix styling * chore: place cursor at the end of text --- .../api/models/task-comment/task-comment.ts | 11 ++ src/app/api/services/task-comment.service.ts | 27 ++++ .../task-comment-composer.component.html | 128 +++++++++++------- .../task-comment-composer.component.ts | 122 ++++++++++++++++- .../comment-bubble-action.component.html | 14 +- .../comment-bubble-action.component.ts | 6 + .../task-comments-viewer.component.ts | 1 + 7 files changed, 255 insertions(+), 54 deletions(-) diff --git a/src/app/api/models/task-comment/task-comment.ts b/src/app/api/models/task-comment/task-comment.ts index e9b9bb55ce..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; @@ -80,6 +82,15 @@ export class TaskComment extends Entity { } 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/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/tasks/task-comment-composer/task-comment-composer.component.html b/src/app/tasks/task-comment-composer/task-comment-composer.component.html index bb218ef35a..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 8f748ae4db..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 @@ -45,6 +45,7 @@ interface ApiError { export interface TaskCommentComposerData { originalComment: TaskComment; + editingComment: TaskComment; } const ACCEPTED_FILE_TYPES = [ @@ -88,6 +89,7 @@ export class TaskCommentComposerComponent implements OnInit, AfterViewInit, DoCh private submittedTaskIds: Set = new Set(); public isSending: boolean = false; + private draftBeforeEdit: string = ''; comment = { text: '', @@ -140,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(); @@ -175,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; @@ -322,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(); } }); } @@ -336,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; } @@ -344,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); @@ -373,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(); + } } } @@ -535,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) => { @@ -676,6 +721,71 @@ export class TaskCommentComposerComponent implements OnInit, AfterViewInit, DoCh 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 + '%')}} - - - - - - - - - - - - - - - - - - -
- - - From ba58972dc9e4efbde6768b45ced1f00135c6a140 Mon Sep 17 00:00:00 2001 From: WaelAlahamdi Date: Wed, 6 May 2026 21:13:19 +1000 Subject: [PATCH 58/58] fix: improve students list mobile layout --- .../students-list.component.html | 272 +++++++++--------- 1 file changed, 137 insertions(+), 135 deletions(-) diff --git a/src/app/units/states/students-list/students-list.component.html b/src/app/units/states/students-list/students-list.component.html index 204f8cecf7..022f68a1fe 100644 --- a/src/app/units/states/students-list/students-list.component.html +++ b/src/app/units/states/students-list/students-list.component.html @@ -113,12 +113,12 @@ id="students-list-sort-portfolio" (click)="sortTableBy('portfolioStatus')" [ngClass]="{ - 'relative z-10bg-gray-300 text-gray-800 border-2 border-black': + 'relative z-10 bg-gray-300 text-gray-800 border-2 border-black': tableSort.order === 'portfolioStatus', 'bg-gray-100 text-gray-800 border-gray-300': tableSort.order !== 'portfolioStatus' }" [attr.aria-pressed]="tableSort.order === 'portfolioStatus'" - class="px-4 py-2rounded-r-md -ml-px" + class="px-4 py-2 rounded-r-md -ml-px" > menu_book Portfolio Status @@ -136,143 +136,145 @@

No students were found using the filters specified.

- - - - - - - - - - - - - - - - - - - + + + + +
- Username - - {{ tableSort.reverse ? 'expand_more' : 'expand_less' }} - - - Name - - {{ tableSort.reverse ? 'expand_more' : 'expand_less' }} - - - Stats - - {{ tableSort.reverse ? 'expand_more' : 'expand_less' }} - - - Flags - - {{ tableSort.reverse ? 'expand_more' : 'expand_less' }} - - - Campus - - {{ tableSort.reverse ? 'expand_more' : 'expand_less' }} - - - Tutorial - - {{ tableSort.reverse ? 'expand_more' : 'expand_less' }} - -
- - - {{ project.student.username || 'N/A' }} - - {{ project.student.name }} - -
- -
+
+ + + + + + + + + + + + + + + + + + + + - - - - - - -
+ Username + + {{ tableSort.reverse ? 'expand_more' : 'expand_less' }} + + + Name + + {{ tableSort.reverse ? 'expand_more' : 'expand_less' }} + + + Stats + + {{ tableSort.reverse ? 'expand_more' : 'expand_less' }} + + + Flags + + {{ tableSort.reverse ? 'expand_more' : 'expand_less' }} + + + Campus + + {{ tableSort.reverse ? 'expand_more' : 'expand_less' }} + + + Tutorial + + {{ tableSort.reverse ? 'expand_more' : 'expand_less' }} + +
+ + + {{ project.student.username || 'N/A' }} + + {{ project.student.name }} + +
+ +
+ + {{ bar.value }}% + +
+
+
+
+ - {{ bar.value }}% + visibility - - - - - - - visibility - - - - - - - - - - -
+ + + + +
+ + + +
+ + + \ No newline at end of file