diff --git a/src/web/app/components/question-submission-form/question-submission-form.component.html b/src/web/app/components/question-submission-form/question-submission-form.component.html index 7c07540e8ee..e2d079b9840 100644 --- a/src/web/app/components/question-submission-form/question-submission-form.component.html +++ b/src/web/app/components/question-submission-form/question-submission-form.component.html @@ -241,6 +241,8 @@

Question {{ model.questionNumber }}: {{ mode {{ isQuestionCountOne ? "Submit Response" : "Submit Response for Question " + model.questionNumber }} + diff --git a/src/web/app/components/question-submission-form/question-submission-form.component.ts b/src/web/app/components/question-submission-form/question-submission-form.component.ts index 21ff6f00d99..59d83d25227 100644 --- a/src/web/app/components/question-submission-form/question-submission-form.component.ts +++ b/src/web/app/components/question-submission-form/question-submission-form.component.ts @@ -93,6 +93,7 @@ export class QuestionSubmissionFormComponent implements DoCheck { this.model.isTabExpandedForRecipients.set(recipient.recipientIdentifier, true); }); + this.hasResponseChanged = Array.from(this.model.hasResponseChangedForRecipients.values()).some((value) => value); } @Input() @@ -118,6 +119,12 @@ export class QuestionSubmissionFormComponent implements DoCheck { @Output() responsesSave: EventEmitter = new EventEmitter(); + @Output() + autoSave: EventEmitter<{ id: string, model: QuestionSubmissionFormModel }> = new EventEmitter(); + + @Output() + resetFeedback: EventEmitter = new EventEmitter(); + @ViewChild(ContributionQuestionConstraintComponent) private contributionQuestionConstraint!: ContributionQuestionConstraintComponent; @@ -168,6 +175,8 @@ export class QuestionSubmissionFormComponent implements DoCheck { visibilityStateMachine: VisibilityStateMachine; isEveryRecipientSorted: boolean = false; + autosaveTimeout: any; + constructor(private feedbackQuestionsService: FeedbackQuestionsService, private feedbackResponseService: FeedbackResponsesService) { this.visibilityStateMachine = @@ -221,6 +230,13 @@ export class QuestionSubmissionFormComponent implements DoCheck { }); } + resetForm(): void { + this.resetFeedback.emit(this.model); + this.isSaved = true; + this.hasResponseChanged = false; + clearTimeout(this.autosaveTimeout); + } + toggleQuestionTab(): void { if (this.currentSelectedSessionView === this.allSessionViews.DEFAULT) { this.model.isTabExpanded = !this.model.isTabExpanded; @@ -350,7 +366,6 @@ export class QuestionSubmissionFormComponent implements DoCheck { */ triggerRecipientSubmissionFormChange(index: number, field: string, data: any): void { if (!this.isFormsDisabled) { - this.hasResponseChanged = true; this.isSubmitAllClickedChange.emit(false); this.model.hasResponseChangedForRecipients.set(this.model.recipientList[index].recipientIdentifier, true); @@ -362,6 +377,12 @@ export class QuestionSubmissionFormComponent implements DoCheck { this.updateIsValidByQuestionConstraint(); this.formModelChange.emit(this.model); + + this.autoSave.emit({ id: this.model.feedbackQuestionId, model: this.model }); + clearTimeout(this.autosaveTimeout); + this.autosaveTimeout = setTimeout(() => { + this.hasResponseChanged = true; + }, 100); // 0.1 second to prevent people from trying to immediately reset before autosave kicks in } } @@ -451,6 +472,7 @@ export class QuestionSubmissionFormComponent implements DoCheck { * Triggers saving of responses for the specific question. */ saveFeedbackResponses(): void { + clearTimeout(this.autosaveTimeout); this.isSaved = true; this.hasResponseChanged = false; this.model.hasResponseChangedForRecipients.forEach( diff --git a/src/web/app/pages-session/session-submission-page/__snapshots__/session-submission-page.component.spec.ts.snap b/src/web/app/pages-session/session-submission-page/__snapshots__/session-submission-page.component.spec.ts.snap index 5110ed14b92..d0f974f68b8 100644 --- a/src/web/app/pages-session/session-submission-page/__snapshots__/session-submission-page.component.spec.ts.snap +++ b/src/web/app/pages-session/session-submission-page/__snapshots__/session-submission-page.component.spec.ts.snap @@ -2,11 +2,13 @@ exports[`SessionSubmissionPageComponent should snap when feedback session questions have failed to load 1`] = ` + @@ -1547,6 +1571,14 @@ exports[`SessionSubmissionPageComponent should snap with feedback session questi Submit Response for Question 3 + @@ -1980,6 +2012,14 @@ exports[`SessionSubmissionPageComponent should snap with feedback session questi Submit Response for Question 4 + @@ -2195,6 +2235,14 @@ exports[`SessionSubmissionPageComponent should snap with feedback session questi Submit Response for Question 5 + @@ -2424,6 +2472,14 @@ exports[`SessionSubmissionPageComponent should snap with feedback session questi Submit Response for Question 6 + @@ -2883,6 +2939,14 @@ exports[`SessionSubmissionPageComponent should snap with feedback session questi Submit Response for Question 7 + @@ -3227,6 +3291,14 @@ exports[`SessionSubmissionPageComponent should snap with feedback session questi Submit Response for Question 8 + @@ -3509,6 +3581,14 @@ exports[`SessionSubmissionPageComponent should snap with feedback session questi Submit Response for Question 9 + @@ -3741,6 +3821,14 @@ exports[`SessionSubmissionPageComponent should snap with feedback session questi Submit Response for Question 10 + @@ -3770,11 +3858,13 @@ exports[`SessionSubmissionPageComponent should snap with feedback session questi exports[`SessionSubmissionPageComponent should snap with feedback session question submission forms when disabled 1`] = ` + @@ -4563,6 +4662,13 @@ exports[`SessionSubmissionPageComponent should snap with feedback session questi Submit Response for Question 3 + @@ -5002,6 +5108,14 @@ exports[`SessionSubmissionPageComponent should snap with feedback session questi Submit Response for Question 4 + @@ -5219,6 +5333,14 @@ exports[`SessionSubmissionPageComponent should snap with feedback session questi Submit Response for Question 5 + @@ -5450,6 +5572,14 @@ exports[`SessionSubmissionPageComponent should snap with feedback session questi Submit Response for Question 6 + @@ -5911,6 +6041,14 @@ exports[`SessionSubmissionPageComponent should snap with feedback session questi Submit Response for Question 7 + @@ -6265,6 +6403,14 @@ exports[`SessionSubmissionPageComponent should snap with feedback session questi Submit Response for Question 8 + @@ -6549,6 +6695,14 @@ exports[`SessionSubmissionPageComponent should snap with feedback session questi Submit Response for Question 9 + @@ -6782,6 +6936,14 @@ exports[`SessionSubmissionPageComponent should snap with feedback session questi Submit Response for Question 10 + @@ -6812,11 +6974,13 @@ exports[`SessionSubmissionPageComponent should snap with feedback session questi exports[`SessionSubmissionPageComponent should snap with user that is logged in and using session link 1`] = ` {{ getRecipientName(entry.key) }}

[(isSubmitAllClicked)]="isSubmitAllClicked" [currentSelectedSessionView]="currentSelectedSessionView" [recipientId]="entry.key" + (resetFeedback)="resetFeedbackResponses([$event], entry.key)" + (autoSave)="handleAutoSave($event)" > @@ -133,6 +135,9 @@

{{ getRecipientName(entry.key) }}

(click)="saveResponsesForSelectedRecipientQuestions(entry.key, questionSubmissionForms)" [disabled]="isSavingResponses || isSubmissionFormsDisabled">Submit Responses for {{ getRecipientName(entry.key) }} + @@ -151,6 +156,8 @@

There are no ungroupable questions

(deleteCommentEvent)="deleteParticipantComment(i, $event)" [isQuestionCountOne]="isQuestionCountOne" [(isSubmitAllClicked)]="isSubmitAllClicked" + (resetFeedback)="resetFeedbackResponses([$event], null)" + (autoSave)="handleAutoSave($event)" > @@ -166,6 +173,8 @@

There are no ungroupable questions

[isQuestionCountOne]="isQuestionCountOne" [(isSubmitAllClicked)]="isSubmitAllClicked" [currentSelectedSessionView]="currentSelectedSessionView" + (resetFeedback)="resetFeedbackResponses([$event], null)" + (autoSave)="handleAutoSave($event)" >
diff --git a/src/web/app/pages-session/session-submission-page/session-submission-page.component.spec.ts b/src/web/app/pages-session/session-submission-page/session-submission-page.component.spec.ts index e83ce3462dd..389870d2cb5 100644 --- a/src/web/app/pages-session/session-submission-page/session-submission-page.component.spec.ts +++ b/src/web/app/pages-session/session-submission-page/session-submission-page.component.spec.ts @@ -1281,4 +1281,38 @@ describe('SessionSubmissionPageComponent', () => { expect(commentSpy).toHaveBeenLastCalledWith(expectedId, Intent.STUDENT_SUBMISSION, { key: testQueryParams.key, moderatedperson: '' }); }); + + it('should autosave data to localStorage', () => { + const questionId = 'feedback-question-id-mcq'; + const model: QuestionSubmissionFormModel = deepCopy(testMcqQuestionSubmissionForm); + model.hasResponseChangedForRecipients = new Map().set('r1', true); + model.isTabExpandedForRecipients = new Map().set('r1', true); + const event = { id: questionId, model }; + const setItemSpy = jest.spyOn(Storage.prototype, 'setItem'); + + jest.useFakeTimers(); + component.handleAutoSave(event); + jest.advanceTimersByTime(component.autoSaveDelay); + + expect(setItemSpy).toHaveBeenCalled(); + jest.useRealTimers(); + }); + + it('should load autosaved data from localStorage', () => { + const questionId = 'feedback-question-id-mcq'; + const savedModel: any = deepCopy(testMcqQuestionSubmissionForm); + savedModel.hasResponseChangedForRecipients = Array.from(new Map().set('r1', true).entries()); + savedModel.isTabExpandedForRecipients = Array.from(new Map().set('r1', true).entries()); + + const getItemSpy = jest.spyOn(Storage.prototype, 'getItem') + .mockReturnValue(JSON.stringify({ [questionId]: savedModel })); + + component.questionSubmissionForms = [deepCopy(testMcqQuestionSubmissionForm)]; + + component.loadAutoSavedData(questionId); + + expect(component.questionSubmissionForms[0].hasResponseChangedForRecipients.get('r1')).toBe(true); + expect(component.questionSubmissionForms[0].isTabExpandedForRecipients.get('r1')).toBe(true); + expect(getItemSpy).toHaveBeenCalledWith('autosave'); + }); }); diff --git a/src/web/app/pages-session/session-submission-page/session-submission-page.component.ts b/src/web/app/pages-session/session-submission-page/session-submission-page.component.ts index 3761289f162..0703cd4cf40 100644 --- a/src/web/app/pages-session/session-submission-page/session-submission-page.component.ts +++ b/src/web/app/pages-session/session-submission-page/session-submission-page.component.ts @@ -103,6 +103,7 @@ export class SessionSubmissionPageComponent implements OnInit, AfterViewInit { intent: Intent = Intent.STUDENT_SUBMISSION; questionSubmissionForms: QuestionSubmissionFormModel[] = []; + originalQuestionSubmissionForms: QuestionSubmissionFormModel[] = []; isSavingResponses: boolean = false; isSubmissionFormsDisabled: boolean = false; @@ -130,8 +131,13 @@ export class SessionSubmissionPageComponent implements OnInit, AfterViewInit { feedbackSessionId: string | undefined = ''; studentId: string | undefined = ''; + autoSaveTimeout: any; + autoSaveDelay = 100; // 0.1 second delay + private backendUrl: string = environment.backendUrl; + private readonly AUTOSAVE_KEY = 'autosave'; + constructor(private route: ActivatedRoute, private statusMessageService: StatusMessageService, private timezoneService: TimezoneService, @@ -152,6 +158,44 @@ export class SessionSubmissionPageComponent implements OnInit, AfterViewInit { this.timezoneService.getTzVersion(); // import timezone service to load timezone data } + handleAutoSave(event: { id: string, model: QuestionSubmissionFormModel }): void { + // Disable autosave in preview mode + if (this.previewAsPerson) { + return; + } + + clearTimeout(this.autoSaveTimeout); + this.autoSaveTimeout = setTimeout(() => { + const savedData = this.getLocalStorageItem(this.AUTOSAVE_KEY); + const clonedModel = { + ...event.model, + hasResponseChangedForRecipients: Array.from(event.model.hasResponseChangedForRecipients.entries()), + isTabExpandedForRecipients: Array.from(event.model.isTabExpandedForRecipients.entries()), + }; + savedData[event.id] = clonedModel; + this.setLocalStorageItem(this.AUTOSAVE_KEY, savedData); + }, this.autoSaveDelay); + } + + loadAutoSavedData(questionId: string): void { + // Disable loading autosaved data in preview mode + if (this.previewAsPerson) { + return; + } + + const savedData = this.getLocalStorageItem(this.AUTOSAVE_KEY); + const savedModel = savedData[questionId]; + + if (savedModel) { + const index = this.questionSubmissionForms.findIndex((q) => q.feedbackQuestionId === questionId); + if (index !== -1) { + savedModel.hasResponseChangedForRecipients = new Map(savedModel.hasResponseChangedForRecipients); + savedModel.isTabExpandedForRecipients = new Map(savedModel.isTabExpandedForRecipients); + this.questionSubmissionForms[index] = savedModel; + } + } + } + ngOnInit(): void { this.route.data.pipe( tap((data: any) => { @@ -641,6 +685,20 @@ export class SessionSubmissionPageComponent implements OnInit, AfterViewInit { }).pipe(finalize(() => { model.isLoading = false; model.isLoaded = true; + + this.originalQuestionSubmissionForms.push({ + ...model, + hasResponseChangedForRecipients: new Map(model.hasResponseChangedForRecipients), + isTabExpandedForRecipients: new Map(model.isTabExpandedForRecipients), + recipientList: model.recipientList.map((recipient) => ({ ...recipient })), + recipientSubmissionForms: model.recipientSubmissionForms.map((form) => ({ + ...form, + responseDetails: { ...form.responseDetails }, + commentByGiver: form.commentByGiver ? { ...form.commentByGiver } : undefined, + })), + questionDetails: { ...model.questionDetails }, + }); + })) .subscribe({ next: (existingResponses: FeedbackResponsesResponse) => { @@ -819,6 +877,30 @@ export class SessionSubmissionPageComponent implements OnInit, AfterViewInit { recipientSubmissionFormModel.commentByGiver = undefined; } }); + + const savedData = this.getLocalStorageItem(this.AUTOSAVE_KEY); + delete savedData[questionSubmissionFormModel.feedbackQuestionId]; + this.setLocalStorageItem(this.AUTOSAVE_KEY, savedData); + + this.originalQuestionSubmissionForms.forEach((originalModel: QuestionSubmissionFormModel) => { + if (originalModel.feedbackQuestionId === questionSubmissionFormModel.feedbackQuestionId) { + originalModel.recipientSubmissionForms.forEach((originalRecipientSubmissionFormModel: + FeedbackResponseRecipientSubmissionFormModel) => { + if (responsesMap[originalRecipientSubmissionFormModel.recipientIdentifier]) { + const correspondingResp: FeedbackResponse = + responsesMap[originalRecipientSubmissionFormModel.recipientIdentifier]; + originalRecipientSubmissionFormModel.responseId = correspondingResp.feedbackResponseId; + originalRecipientSubmissionFormModel.responseDetails = correspondingResp.responseDetails; + originalRecipientSubmissionFormModel.recipientIdentifier = + correspondingResp.recipientIdentifier; + } else { + originalRecipientSubmissionFormModel.responseId = ''; + originalRecipientSubmissionFormModel.commentByGiver = undefined; + } + }); + } + + }); }), switchMap(() => forkJoin(questionSubmissionFormModel.recipientSubmissionForms @@ -992,6 +1074,7 @@ export class SessionSubmissionPageComponent implements OnInit, AfterViewInit { if (event && event.visible && !questionSubmissionForm.isLoaded && !questionSubmissionForm.isLoading) { questionSubmissionForm.isLoading = true; this.loadFeedbackQuestionRecipientsForQuestion(questionSubmissionForm); + this.loadAutoSavedData(questionSubmissionForm.feedbackQuestionId); } } @@ -1028,6 +1111,101 @@ export class SessionSubmissionPageComponent implements OnInit, AfterViewInit { this.saveFeedbackResponses(recipientQSForms, false, recipientId); } + resetResponsesForSelectedRecipientQuestions(recipientId: string, + questionSubmissionForms: QuestionSubmissionFormModel[]): void { + + const questionsToRecipient: Set | undefined = this.recipientQuestionMap.get(recipientId); + if (!questionsToRecipient) { + this.statusMessageService.showErrorToast('Failed to reset response for this recipient. ' + + 'Please switch back to "Group by Question" view to reset responses.'); + } + const recipientQSForms = questionSubmissionForms + .filter((questionSubmissionFormModel: QuestionSubmissionFormModel) => + questionsToRecipient!.has(questionSubmissionFormModel.questionNumber)); + this.resetFeedbackResponses(recipientQSForms, recipientId); + } + + resetFeedbackResponses(questionSubmissionForms: QuestionSubmissionFormModel[], recipientId: string | null): void { + const savedData = this.getLocalStorageItem(this.AUTOSAVE_KEY); + + questionSubmissionForms.forEach((questionSubmissionFormModel: QuestionSubmissionFormModel) => { + const originalSubmissionForm = this.originalQuestionSubmissionForms.find( + (originalModel: QuestionSubmissionFormModel) => + originalModel.feedbackQuestionId === questionSubmissionFormModel.feedbackQuestionId, + ); + + if (originalSubmissionForm) { + if (recipientId) { + questionSubmissionFormModel.recipientSubmissionForms.forEach((form, index) => { + if (form.recipientIdentifier === recipientId) { + const originalForm = originalSubmissionForm.recipientSubmissionForms.find( + (originalRecipientForm) => originalRecipientForm.recipientIdentifier === form.recipientIdentifier, + ); + + if (originalForm) { + questionSubmissionFormModel.recipientSubmissionForms[index] = { + ...originalForm, + responseDetails: { ...originalForm.responseDetails }, + commentByGiver: originalForm.commentByGiver ? { ...originalForm.commentByGiver } : undefined, + }; + } + } + }); + + questionSubmissionFormModel.hasResponseChangedForRecipients.set( + recipientId, originalSubmissionForm.hasResponseChangedForRecipients.get(recipientId) ?? false, + ); + questionSubmissionFormModel.isTabExpandedForRecipients.set( + recipientId, originalSubmissionForm.isTabExpandedForRecipients.get(recipientId) ?? true, + ); + + if (savedData[questionSubmissionFormModel.feedbackQuestionId]) { + const recipientIndex = savedData[questionSubmissionFormModel.feedbackQuestionId].recipientSubmissionForms + .findIndex((form: FeedbackResponseRecipientSubmissionFormModel) => + form.recipientIdentifier === recipientId); + + if (recipientIndex !== -1) { + savedData[questionSubmissionFormModel.feedbackQuestionId] + .recipientSubmissionForms.splice(recipientIndex, 1); + } + + if (savedData[questionSubmissionFormModel.feedbackQuestionId].recipientSubmissionForms.length === 0) { + delete savedData[questionSubmissionFormModel.feedbackQuestionId]; + } + } + } else { + Object.assign(questionSubmissionFormModel, { + ...originalSubmissionForm, + recipientSubmissionForms: originalSubmissionForm.recipientSubmissionForms + .map((form: FeedbackResponseRecipientSubmissionFormModel) => ({ + ...form, + responseDetails: { ...form.responseDetails }, + commentByGiver: form.commentByGiver ? { ...form.commentByGiver } : undefined, + })), + hasResponseChangedForRecipients: new Map(originalSubmissionForm.hasResponseChangedForRecipients), + isTabExpandedForRecipients: new Map(originalSubmissionForm.isTabExpandedForRecipients), + questionDetails: { ...originalSubmissionForm.questionDetails }, + }); + + delete savedData[questionSubmissionFormModel.feedbackQuestionId]; + } + } + }); + + this.setLocalStorageItem(this.AUTOSAVE_KEY, savedData); + } + + hasResponseChangedForRecipient(recipientId: string, + questionSubmissionForms: QuestionSubmissionFormModel[]): boolean { + const questionsToRecipient: Set | undefined = this.recipientQuestionMap.get(recipientId); + if (!questionsToRecipient) { + return false; + } + return questionSubmissionForms.some((questionSubmissionFormModel: QuestionSubmissionFormModel) => + questionsToRecipient.has(questionSubmissionFormModel.questionNumber) + && questionSubmissionFormModel.hasResponseChangedForRecipients.get(recipientId)); + } + private addQuestionForRecipient(recipientId: string, questionId: any): void { if (this.recipientQuestionMap.has(recipientId)) { this.recipientQuestionMap.get(recipientId)!.add(questionId); @@ -1153,4 +1331,18 @@ export class SessionSubmissionPageComponent implements OnInit, AfterViewInit { studentId: this.studentId, }).subscribe(); } + + /** + * Utility method to get item from local storage. + */ + private getLocalStorageItem(key: string): any { + return JSON.parse(localStorage.getItem(key) || '{}'); + } + + /** + * Utility method to set item in local storage. + */ + private setLocalStorageItem(key: string, data: any): void { + localStorage.setItem(key, JSON.stringify(data)); + } }