From 21b8ee45c2e904d242e66610cf1ac84cc311c1c9 Mon Sep 17 00:00:00 2001 From: Saniya <37302318+Saby-Bishops@users.noreply.github.com> Date: Tue, 25 Nov 2025 14:16:55 -0600 Subject: [PATCH 1/3] Use typed form controls in exams view --- src/app/exams/exams-view.component.ts | 104 ++++++++++++++++---------- 1 file changed, 64 insertions(+), 40 deletions(-) diff --git a/src/app/exams/exams-view.component.ts b/src/app/exams/exams-view.component.ts index fa1238ceca..9084aafb5f 100644 --- a/src/app/exams/exams-view.component.ts +++ b/src/app/exams/exams-view.component.ts @@ -1,5 +1,5 @@ import { Component, OnInit, OnDestroy, Input } from '@angular/core'; -import { UntypedFormControl, AbstractControl } from '@angular/forms'; +import { FormControl, AbstractControl, ValidationErrors } from '@angular/forms'; import { Router, ActivatedRoute, ParamMap } from '@angular/router'; import { MatLegacyDialog as MatDialog } from '@angular/material/legacy-dialog'; import { Subject, forkJoin, of } from 'rxjs'; @@ -15,6 +15,10 @@ import { } from '../shared/dialogs/dialogs-announcement.component'; import { StateService } from '../shared/state.service'; +type ExamAnswerOption = { id: string; text: string; isOther?: boolean }; +type ExamOtherAnswerOption = ExamAnswerOption & { isOther: true }; +type ExamAnswerValue = ExamAnswerOption | ExamAnswerOption[] | string; + @Component({ selector: 'planet-exams-view', templateUrl: './exams-view.component.html', @@ -33,7 +37,7 @@ export class ExamsViewComponent implements OnInit, OnDestroy { question: ExamQuestion; stepNum = 0; maxQuestions = 0; - answer = new UntypedFormControl(null, this.answerValidator); + answer = new FormControl(null, { validators: this.answerValidator.bind(this) }); statusMessage = ''; spinnerOn = true; title = ''; @@ -52,7 +56,7 @@ export class ExamsViewComponent implements OnInit, OnDestroy { isLoading = true; courseId: string; teamId = this.route.snapshot.params.teamId || null; - currentOtherOption: { id: 'other'; text: string; isOther: true } | null = null; + currentOtherOption: ExamOtherAnswerOption | null = null; constructor( private router: Router, @@ -289,25 +293,25 @@ export class ExamsViewComponent implements OnInit, OnDestroy { }); } - setAnswer(event, option) { - const value = this.answer.value || []; + setAnswer(event, option: ExamAnswerOption) { + const currentValue = Array.isArray(this.answer.value) ? [ ...this.answer.value ] : []; if (event.checked) { - if (!value.some(val => val.id === option.id)) { - value.push(option); + if (!currentValue.some(val => val.id === option.id)) { + currentValue.push(option); } else if (option.id === 'other') { - const otherIndex = value.findIndex(val => val.id === 'other'); + const otherIndex = currentValue.findIndex(val => val.id === 'other'); if (otherIndex > -1) { - value[otherIndex].text = option.text; + currentValue[otherIndex].text = option.text; } } } else { - const index = value.findIndex(val => val.id === option.id); + const index = currentValue.findIndex(val => val.id === option.id); if (index > -1) { - value.splice(index, 1); + currentValue.splice(index, 1); } } - this.answer.setValue(value.length > 0 ? value : null); + this.answer.setValue(currentValue.length > 0 ? currentValue : null); this.answer.updateValueAndValidity(); this.checkboxState[option.id] = event.checked; } @@ -319,14 +323,19 @@ export class ExamsViewComponent implements OnInit, OnDestroy { calculateCorrect() { const value = this.answer.value; - const answers = value instanceof Array ? value : [ value ]; - if (answers.every(answer => answer === null || answer === undefined)) { + const answers = Array.isArray(value) + ? value + : this.isAnswerOption(value) ? [ value ] : []; + + if (answers.length === 0) { return undefined; } - const isMultiCorrect = (correctChoice, ans: any[]) => ( - correctChoice.every(choice => ans.find((a: any) => a.id === choice)) && - ans.every((a: any) => correctChoice.find(choice => a.id === choice)) + + const isMultiCorrect = (correctChoice: string[], ans: ExamAnswerOption[]) => ( + correctChoice.every(choice => ans.find((a: ExamAnswerOption) => a.id === choice)) && + ans.every((a: ExamAnswerOption) => correctChoice.find(choice => a.id === choice)) ); + return this.question.correctChoice instanceof Array ? isMultiCorrect(this.question.correctChoice, answers) : answers[0].id === this.question.correctChoice; @@ -348,40 +357,43 @@ export class ExamsViewComponent implements OnInit, OnDestroy { } setAnswerForRetake(answer: any) { - const setSelectMultipleAnswer = (answers: any[]) => { + const setSelectMultipleAnswer = (answers: ExamAnswerOption[]) => { answers.forEach(ans => { this.setAnswer({ checked: true }, ans); }); }; this.answer.setValue(null); - if (!answer.value) { + const answerValue = answer.value as ExamAnswerValue | null; + if (!answerValue) { return; } switch (this.question.type) { case 'selectMultiple': - const rebuilt = answer.value.map(val => { - if (val.id === 'other') { - this.currentOtherOption.text = val.text || ''; - return this.currentOtherOption; - } - return val; - }); - setSelectMultipleAnswer(rebuilt); + if (Array.isArray(answerValue)) { + const rebuilt = answerValue.map(val => { + if (val.id === 'other' && this.currentOtherOption) { + this.currentOtherOption.text = val.text || ''; + return this.currentOtherOption; + } + return val; + }); + setSelectMultipleAnswer(rebuilt); + } break; case 'select': - if (answer.value && answer.value.id === 'other') { - this.currentOtherOption.text = answer.value.text; + if (this.isOtherOption(answerValue) && this.currentOtherOption) { + this.currentOtherOption.text = answerValue.text; this.answer.setValue(this.currentOtherOption); - } else { - this.answer.setValue(this.question.choices.find((choice) => choice.text === answer.value.text)); + } else if (this.isAnswerOption(answerValue)) { + this.answer.setValue(this.question.choices.find((choice) => choice.text === answerValue.text) || null); } break; default: - this.answer.setValue(answer.value); + this.answer.setValue(answerValue); } } - answerValidator(ac: AbstractControl) { + answerValidator(ac: AbstractControl): ValidationErrors | null { if (typeof ac.value === 'string') { return ac.value.trim() ? null : { required: true }; } @@ -391,33 +403,37 @@ export class ExamsViewComponent implements OnInit, OnDestroy { return { required: true }; } const hasEmptyOther = ac.value.some(option => - option && option.isOther && (!option.text || !option.text.trim()) + this.isOtherOption(option) && (!option.text || !option.text.trim()) ); return hasEmptyOther ? { required: true } : null; } - if (ac.value && ac.value.isOther && (!ac.value.text || !ac.value.text.trim())) { - return { required: true }; + + if (this.isOtherOption(ac.value)) { + return ac.value.text && ac.value.text.trim() ? null : { required: true }; } return ac.value !== null && ac.value !== undefined ? null : { required: true }; } setViewAnswerText(answer: any) { - this.answer.setValue(Array.isArray(answer.value) ? answer.value.map((a: any) => a.text).join(', ').trim() : answer.value); + const answerValue = answer.value as ExamAnswerValue | null; + this.answer.setValue(Array.isArray(answerValue) ? answerValue.map((a: ExamAnswerOption) => a.text).join(', ').trim() : answerValue); this.grade = answer.grade; this.comment = answer.gradeComment; } isOtherSelected() { - return this.answer.value?.id === 'other'; + return this.isOtherOption(this.answer.value); } toggleOtherMultiple({ checked }): void { this.checkboxState['other'] = checked; if (checked) { - this.setAnswer({ checked: true }, this.currentOtherOption); + if (this.currentOtherOption) { + this.setAnswer({ checked: true }, this.currentOtherOption); + } } else { - const remaining = (this.answer.value || []).filter(o => o.id !== 'other'); + const remaining = (Array.isArray(this.answer.value) ? this.answer.value : []).filter(o => o.id !== 'other'); this.answer.setValue(remaining.length ? remaining : null); this.answer.updateValueAndValidity(); } @@ -427,4 +443,12 @@ export class ExamsViewComponent implements OnInit, OnDestroy { this.answer.updateValueAndValidity(); } + private isAnswerOption(value: ExamAnswerValue | null): value is ExamAnswerOption { + return !!value && !Array.isArray(value) && typeof value === 'object' && 'id' in value; + } + + private isOtherOption(value: ExamAnswerValue | null): value is ExamOtherAnswerOption { + return this.isAnswerOption(value) && value.isOther === true; + } + } From 1f9ac590377cc9d5b16f3454b221204959744eec Mon Sep 17 00:00:00 2001 From: Saniya <37302318+Saby-Bishops@users.noreply.github.com> Date: Thu, 27 Nov 2025 14:21:20 -0600 Subject: [PATCH 2/3] Type checkbox interactions in exams view --- src/app/exams/exams-view.component.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/app/exams/exams-view.component.ts b/src/app/exams/exams-view.component.ts index 9084aafb5f..99b07e0836 100644 --- a/src/app/exams/exams-view.component.ts +++ b/src/app/exams/exams-view.component.ts @@ -2,6 +2,7 @@ import { Component, OnInit, OnDestroy, Input } from '@angular/core'; import { FormControl, AbstractControl, ValidationErrors } from '@angular/forms'; import { Router, ActivatedRoute, ParamMap } from '@angular/router'; import { MatLegacyDialog as MatDialog } from '@angular/material/legacy-dialog'; +import { MatCheckboxChange } from '@angular/material/checkbox'; import { Subject, forkJoin, of } from 'rxjs'; import { takeUntil, switchMap, catchError } from 'rxjs/operators'; import { CoursesService } from '../courses/courses.service'; @@ -37,7 +38,7 @@ export class ExamsViewComponent implements OnInit, OnDestroy { question: ExamQuestion; stepNum = 0; maxQuestions = 0; - answer = new FormControl(null, { validators: this.answerValidator.bind(this) }); + answer = new FormControl(null, { validators: this.answerValidator }); statusMessage = ''; spinnerOn = true; title = ''; @@ -293,7 +294,7 @@ export class ExamsViewComponent implements OnInit, OnDestroy { }); } - setAnswer(event, option: ExamAnswerOption) { + setAnswer(event: Pick, option: ExamAnswerOption) { const currentValue = Array.isArray(this.answer.value) ? [ ...this.answer.value ] : []; if (event.checked) { if (!currentValue.some(val => val.id === option.id)) { @@ -393,7 +394,7 @@ export class ExamsViewComponent implements OnInit, OnDestroy { } } - answerValidator(ac: AbstractControl): ValidationErrors | null { + answerValidator = (ac: AbstractControl): ValidationErrors | null => { if (typeof ac.value === 'string') { return ac.value.trim() ? null : { required: true }; } @@ -413,7 +414,7 @@ export class ExamsViewComponent implements OnInit, OnDestroy { } return ac.value !== null && ac.value !== undefined ? null : { required: true }; - } + }; setViewAnswerText(answer: any) { const answerValue = answer.value as ExamAnswerValue | null; @@ -426,7 +427,7 @@ export class ExamsViewComponent implements OnInit, OnDestroy { return this.isOtherOption(this.answer.value); } - toggleOtherMultiple({ checked }): void { + toggleOtherMultiple({ checked }: Pick): void { this.checkboxState['other'] = checked; if (checked) { if (this.currentOtherOption) { From 94120cf228437ebb16e65cf157f811a643c831dd Mon Sep 17 00:00:00 2001 From: Saniya <37302318+Saby-Bishops@users.noreply.github.com> Date: Tue, 2 Dec 2025 13:45:36 -0600 Subject: [PATCH 3/3] Fix answer validator initialization order --- src/app/exams/exams-view.component.ts | 43 +++++++++++++-------------- 1 file changed, 21 insertions(+), 22 deletions(-) diff --git a/src/app/exams/exams-view.component.ts b/src/app/exams/exams-view.component.ts index 99b07e0836..72fa82b34a 100644 --- a/src/app/exams/exams-view.component.ts +++ b/src/app/exams/exams-view.component.ts @@ -38,6 +38,27 @@ export class ExamsViewComponent implements OnInit, OnDestroy { question: ExamQuestion; stepNum = 0; maxQuestions = 0; + answerValidator = (ac: AbstractControl): ValidationErrors | null => { + if (typeof ac.value === 'string') { + return ac.value.trim() ? null : { required: true }; + } + + if (Array.isArray(ac.value)) { + if (ac.value.length === 0) { + return { required: true }; + } + const hasEmptyOther = ac.value.some(option => + this.isOtherOption(option) && (!option.text || !option.text.trim()) + ); + return hasEmptyOther ? { required: true } : null; + } + + if (this.isOtherOption(ac.value)) { + return ac.value.text && ac.value.text.trim() ? null : { required: true }; + } + + return ac.value !== null && ac.value !== undefined ? null : { required: true }; + }; answer = new FormControl(null, { validators: this.answerValidator }); statusMessage = ''; spinnerOn = true; @@ -394,28 +415,6 @@ export class ExamsViewComponent implements OnInit, OnDestroy { } } - answerValidator = (ac: AbstractControl): ValidationErrors | null => { - if (typeof ac.value === 'string') { - return ac.value.trim() ? null : { required: true }; - } - - if (Array.isArray(ac.value)) { - if (ac.value.length === 0) { - return { required: true }; - } - const hasEmptyOther = ac.value.some(option => - this.isOtherOption(option) && (!option.text || !option.text.trim()) - ); - return hasEmptyOther ? { required: true } : null; - } - - if (this.isOtherOption(ac.value)) { - return ac.value.text && ac.value.text.trim() ? null : { required: true }; - } - - return ac.value !== null && ac.value !== undefined ? null : { required: true }; - }; - setViewAnswerText(answer: any) { const answerValue = answer.value as ExamAnswerValue | null; this.answer.setValue(Array.isArray(answerValue) ? answerValue.map((a: ExamAnswerOption) => a.text).join(', ').trim() : answerValue);