From ea06e8f6a3c23aacc6675d7b7e0e0a74f58aed1b Mon Sep 17 00:00:00 2001 From: Saniya <37302318+Saby-Bishops@users.noreply.github.com> Date: Wed, 26 Nov 2025 14:15:22 -0600 Subject: [PATCH 1/4] Refactor exams view form typing --- src/app/exams/exams-view.component.ts | 97 ++++++++++++++++----------- 1 file changed, 57 insertions(+), 40 deletions(-) diff --git a/src/app/exams/exams-view.component.ts b/src/app/exams/exams-view.component.ts index fa1238ceca..3b7a61b553 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 { AbstractControl, FormBuilder, FormControl, FormGroup, ValidationErrors, ValidatorFn } 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,14 @@ import { } from '../shared/dialogs/dialogs-announcement.component'; import { StateService } from '../shared/state.service'; +type ExamAnswerOption = { id: string; text: string; isOther?: boolean }; + +type ExamAnswerValue = string | ExamAnswerOption | ExamAnswerOption[] | null; + +interface ExamViewForm { + answer: FormControl; +} + @Component({ selector: 'planet-exams-view', templateUrl: './exams-view.component.html', @@ -33,7 +41,32 @@ export class ExamsViewComponent implements OnInit, OnDestroy { question: ExamQuestion; stepNum = 0; maxQuestions = 0; - answer = new UntypedFormControl(null, this.answerValidator); + private readonly answerValidator: ValidatorFn = (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 => + option && option.isOther && (!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 }; + } + + return ac.value !== null && ac.value !== undefined ? null : { required: true }; + }; + + readonly examForm: FormGroup; + + get answer(): FormControl { + return this.examForm.controls.answer; + } statusMessage = ''; spinnerOn = true; title = ''; @@ -52,7 +85,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: ExamAnswerOption & { isOther: true } = { id: 'other', text: '', isOther: true }; constructor( private router: Router, @@ -64,7 +97,12 @@ export class ExamsViewComponent implements OnInit, OnDestroy { private planetMessageService: PlanetMessageService, private dialog: MatDialog, private stateService: StateService, - ) { } + private formBuilder: FormBuilder, + ) { + this.examForm = this.formBuilder.group({ + answer: this.formBuilder.control(null, { validators: this.answerValidator }) + }); + } ngOnInit() { this.setCourseListener(); @@ -289,16 +327,14 @@ export class ExamsViewComponent implements OnInit, OnDestroy { }); } - setAnswer(event, option) { - const value = this.answer.value || []; + setAnswer(event: { checked: boolean }, option: ExamAnswerOption) { + const value: ExamAnswerOption[] = Array.isArray(this.answer.value) ? [ ...this.answer.value ] : []; if (event.checked) { - if (!value.some(val => val.id === option.id)) { + const existingIndex = value.findIndex(val => val.id === option.id); + if (existingIndex === -1) { value.push(option); } else if (option.id === 'other') { - const otherIndex = value.findIndex(val => val.id === 'other'); - if (otherIndex > -1) { - value[otherIndex].text = option.text; - } + value[existingIndex] = { ...value[existingIndex], text: option.text }; } } else { const index = value.findIndex(val => val.id === option.id); @@ -319,7 +355,7 @@ export class ExamsViewComponent implements OnInit, OnDestroy { calculateCorrect() { const value = this.answer.value; - const answers = value instanceof Array ? value : [ value ]; + const answers = Array.isArray(value) ? value : [ value ]; if (answers.every(answer => answer === null || answer === undefined)) { return undefined; } @@ -348,7 +384,7 @@ 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); }); @@ -359,7 +395,7 @@ export class ExamsViewComponent implements OnInit, OnDestroy { } switch (this.question.type) { case 'selectMultiple': - const rebuilt = answer.value.map(val => { + const rebuilt = (answer.value as ExamAnswerOption[]).map(val => { if (val.id === 'other') { this.currentOtherOption.text = val.text || ''; return this.currentOtherOption; @@ -373,33 +409,13 @@ export class ExamsViewComponent implements OnInit, OnDestroy { this.currentOtherOption.text = answer.value.text; this.answer.setValue(this.currentOtherOption); } else { - this.answer.setValue(this.question.choices.find((choice) => choice.text === answer.value.text)); + const selectedChoice = this.question.choices.find((choice) => choice.text === answer.value.text) || null; + this.answer.setValue(selectedChoice); } break; default: - this.answer.setValue(answer.value); - } - } - - answerValidator(ac: AbstractControl) { - 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 => - option && option.isOther && (!option.text || !option.text.trim()) - ); - return hasEmptyOther ? { required: true } : null; + this.answer.setValue(answer.value as ExamAnswerValue); } - if (ac.value && ac.value.isOther && (!ac.value.text || !ac.value.text.trim())) { - return { required: true }; - } - - return ac.value !== null && ac.value !== undefined ? null : { required: true }; } setViewAnswerText(answer: any) { @@ -409,15 +425,16 @@ export class ExamsViewComponent implements OnInit, OnDestroy { } isOtherSelected() { - return this.answer.value?.id === 'other'; + const value = this.answer.value; + return !!value && !Array.isArray(value) && typeof value !== 'string' && value.id === 'other'; } - toggleOtherMultiple({ checked }): void { + toggleOtherMultiple({ checked }: { checked: boolean }): void { this.checkboxState['other'] = checked; if (checked) { 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(); } From 7df831df008de2e4f156a04e0c3d1b1b688a0d31 Mon Sep 17 00:00:00 2001 From: Saniya <37302318+Saby-Bishops@users.noreply.github.com> Date: Thu, 27 Nov 2025 14:21:25 -0600 Subject: [PATCH 2/4] Handle string answers in calculateCorrect --- src/app/exams/exams-view.component.ts | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/src/app/exams/exams-view.component.ts b/src/app/exams/exams-view.component.ts index 3b7a61b553..4f1711cc35 100644 --- a/src/app/exams/exams-view.component.ts +++ b/src/app/exams/exams-view.component.ts @@ -356,16 +356,22 @@ export class ExamsViewComponent implements OnInit, OnDestroy { calculateCorrect() { const value = this.answer.value; const answers = Array.isArray(value) ? value : [ value ]; - if (answers.every(answer => answer === null || answer === undefined)) { + const answerIds = answers + .map(ans => typeof ans === 'string' ? ans : ans?.id) + .filter((id): id is string => !!id); + + if (answerIds.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: string[]) => ( + correctChoice.every(choice => ans.includes(choice)) && + ans.every(answer => correctChoice.includes(answer)) ); + return this.question.correctChoice instanceof Array ? - isMultiCorrect(this.question.correctChoice, answers) : - answers[0].id === this.question.correctChoice; + isMultiCorrect(this.question.correctChoice, answerIds) : + answerIds[0] === this.question.correctChoice; } createAnswerObservable(isFinish = false) { From 0dfb13e938b6b47ed4856c35d50ca199be7d36d8 Mon Sep 17 00:00:00 2001 From: mutugiii Date: Tue, 6 Jan 2026 16:45:54 +0300 Subject: [PATCH 3/4] cleanup --- src/app/exams/exams-view.component.ts | 112 +++++++++++++++----------- 1 file changed, 67 insertions(+), 45 deletions(-) diff --git a/src/app/exams/exams-view.component.ts b/src/app/exams/exams-view.component.ts index 4f1711cc35..dd6ca74ae8 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 { AbstractControl, FormBuilder, FormControl, FormGroup, ValidationErrors, ValidatorFn } 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'; @@ -15,7 +16,13 @@ import { } from '../shared/dialogs/dialogs-announcement.component'; import { StateService } from '../shared/state.service'; -type ExamAnswerOption = { id: string; text: string; isOther?: boolean }; +interface ExamAnswerOption { + id: string; + text: string; + isOther?: boolean; +}; + +type ExamOtherAnswerOption = ExamAnswerOption & { isOther: true }; type ExamAnswerValue = string | ExamAnswerOption | ExamAnswerOption[] | null; @@ -41,32 +48,6 @@ export class ExamsViewComponent implements OnInit, OnDestroy { question: ExamQuestion; stepNum = 0; maxQuestions = 0; - private readonly answerValidator: ValidatorFn = (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 => - option && option.isOther && (!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 }; - } - - return ac.value !== null && ac.value !== undefined ? null : { required: true }; - }; - - readonly examForm: FormGroup; - - get answer(): FormControl { - return this.examForm.controls.answer; - } statusMessage = ''; spinnerOn = true; title = ''; @@ -85,7 +66,34 @@ export class ExamsViewComponent implements OnInit, OnDestroy { isLoading = true; courseId: string; teamId = this.route.snapshot.params.teamId || null; - currentOtherOption: ExamAnswerOption & { isOther: true } = { id: 'other', text: '', isOther: true }; + currentOtherOption: ExamOtherAnswerOption = { id: 'other', text: '', isOther: true }; + private readonly answerValidator: ValidatorFn = (ac: AbstractControl): ValidationErrors | null => { + const value = ac.value; + if (typeof value === 'string') { + return value.trim() ? null : { required: true }; + } + + if (Array.isArray(value)) { + if (value.length === 0) { + return { required: true }; + } + const hasEmptyOther = value.some(option => + this.isOtherOption(option) && (!option.text || !option.text.trim()) + ); + return hasEmptyOther ? { required: true } : null; + } + + if (this.isOtherOption(value)) { + return value.text && value.text.trim() ? null : { required: true }; + } + + return value !== null && value !== undefined ? null : { required: true }; + }; + + readonly examForm: FormGroup; + get answer(): FormControl { + return this.examForm.controls.answer; + } constructor( private router: Router, @@ -327,7 +335,7 @@ export class ExamsViewComponent implements OnInit, OnDestroy { }); } - setAnswer(event: { checked: boolean }, option: ExamAnswerOption) { + setAnswer(event: Pick, option: ExamAnswerOption) { const value: ExamAnswerOption[] = Array.isArray(this.answer.value) ? [ ...this.answer.value ] : []; if (event.checked) { const existingIndex = value.findIndex(val => val.id === option.id); @@ -355,7 +363,7 @@ export class ExamsViewComponent implements OnInit, OnDestroy { calculateCorrect() { const value = this.answer.value; - const answers = Array.isArray(value) ? value : [ value ]; + const answers = Array.isArray(value) ? value : this.isAnswerOption(value) ? [ value ] : []; const answerIds = answers .map(ans => typeof ans === 'string' ? ans : ans?.id) .filter((id): id is string => !!id); @@ -401,22 +409,26 @@ export class ExamsViewComponent implements OnInit, OnDestroy { } switch (this.question.type) { case 'selectMultiple': - const rebuilt = (answer.value as ExamAnswerOption[]).map(val => { - if (val.id === 'other') { - this.currentOtherOption.text = val.text || ''; - return this.currentOtherOption; - } - return val; - }); - setSelectMultipleAnswer(rebuilt); + if (Array.isArray(answer.value)) { + const rebuilt = answer.value.map(val => { + if (val.id === 'other') { + this.currentOtherOption.text = val.text || ''; + return this.currentOtherOption; + } + return val; + }); + setSelectMultipleAnswer(rebuilt); + } break; case 'select': - if (answer.value && answer.value.id === 'other') { + if (this.isOtherOption(answer.value)) { this.currentOtherOption.text = answer.value.text; this.answer.setValue(this.currentOtherOption); - } else { + } else if (this.isAnswerOption(answer.value)) { const selectedChoice = this.question.choices.find((choice) => choice.text === answer.value.text) || null; this.answer.setValue(selectedChoice); + } else { + this.answer.setValue(answer.value as ExamAnswerValue); } break; default: @@ -425,20 +437,22 @@ export class ExamsViewComponent implements OnInit, OnDestroy { } 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() { - const value = this.answer.value; - return !!value && !Array.isArray(value) && typeof value !== 'string' && value.id === 'other'; + return this.isOtherOption(this.answer.value); } - toggleOtherMultiple({ checked }: { checked: boolean }): void { + toggleOtherMultiple({ checked }: Pick): 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 = Array.isArray(this.answer.value) ? this.answer.value.filter(o => o.id !== 'other') : []; this.answer.setValue(remaining.length ? remaining : null); @@ -450,4 +464,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 60a3bacd88577e9d8b3b5eafbd6368a340478916 Mon Sep 17 00:00:00 2001 From: mutugiii Date: Tue, 6 Jan 2026 16:47:57 +0300 Subject: [PATCH 4/4] linting fixes --- src/app/community/community-link-dialog.component.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/community/community-link-dialog.component.ts b/src/app/community/community-link-dialog.component.ts index 28813e133d..c3a276aa29 100644 --- a/src/app/community/community-link-dialog.component.ts +++ b/src/app/community/community-link-dialog.component.ts @@ -18,13 +18,13 @@ interface CommunityLinkForm { platform: FormControl; } -type CommunityLinkSelection = { +interface CommunityLinkSelection { db: 'teams' | 'social'; title: string; selector?: { type: 'team' | 'enterprise' }; }; -type TeamSelectionEvent = { +interface TeamSelectionEvent { mode: 'team' | 'enterprise'; teamId: string; teamType: string;