import { Component, OnInit, inject, signal, computed } from '@angular/core'; import { CommonModule } from '@angular/common'; import { FormBuilder, FormGroup, FormArray, Validators, ReactiveFormsModule, AbstractControl, ValidationErrors } from '@angular/forms'; import { Router, ActivatedRoute } from '@angular/router'; import { MatCardModule } from '@angular/material/card'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; import { MatSelectModule } from '@angular/material/select'; import { MatButtonModule } from '@angular/material/button'; import { MatIconModule } from '@angular/material/icon'; import { MatChipsModule, MatChipInputEvent } from '@angular/material/chips'; import { MatCheckboxModule } from '@angular/material/checkbox'; import { MatRadioModule } from '@angular/material/radio'; import { MatDividerModule } from '@angular/material/divider'; import { MatTooltipModule } from '@angular/material/tooltip'; import { COMMA, ENTER } from '@angular/cdk/keycodes'; import { AdminService } from '../../../core/services/admin.service'; import { CategoryService } from '../../../core/services/category.service'; import { Question, QuestionFormData } from '../../../core/models/question.model'; import { QuestionType, Difficulty } from '../../../core/models/category.model'; /** * AdminQuestionFormComponent * * Comprehensive form for creating new quiz questions. * * Features: * - Dynamic form based on question type * - Real-time validation * - Question preview panel * - Tag input with chips * - Dynamic options for MCQ * - Correct answer validation * - Category selection * - Difficulty levels * - Guest accessibility toggle * * Question Types: * - Multiple Choice: Radio options with dynamic add/remove * - True/False: Pre-defined boolean options * - Written: Text-based answer */ @Component({ selector: 'app-admin-question-form', standalone: true, imports: [ CommonModule, ReactiveFormsModule, MatCardModule, MatFormFieldModule, MatInputModule, MatSelectModule, MatButtonModule, MatIconModule, MatChipsModule, MatCheckboxModule, MatRadioModule, MatDividerModule, MatTooltipModule ], templateUrl: './admin-question-form.component.html', styleUrl: './admin-question-form.component.scss' }) export class AdminQuestionFormComponent implements OnInit { private readonly fb = inject(FormBuilder); private readonly adminService = inject(AdminService); private readonly categoryService = inject(CategoryService); private readonly router = inject(Router); private readonly route = inject(ActivatedRoute); // Form state questionForm!: FormGroup; isSubmitting = signal(false); isEditMode = signal(false); questionId = signal(null); isLoadingQuestion = signal(false); // Categories from service readonly categories = this.categoryService.categories; readonly isLoadingCategories = this.categoryService.isLoading; // Chip input config readonly separatorKeysCodes: number[] = [ENTER, COMMA]; // Available options readonly questionTypes = [ { value: 'multiple_choice', label: 'Multiple Choice' }, { value: 'true_false', label: 'True/False' }, { value: 'written', label: 'Written Answer' } ]; readonly difficultyLevels = [ { value: 'easy', label: 'Easy' }, { value: 'medium', label: 'Medium' }, { value: 'hard', label: 'Hard' } ]; // Computed properties readonly selectedQuestionType = computed(() => { return this.questionForm?.get('questionType')?.value as QuestionType; }); readonly showOptions = computed(() => { const type = this.selectedQuestionType(); return type === 'multiple_choice'; }); readonly showTrueFalse = computed(() => { const type = this.selectedQuestionType(); return type === 'true_false'; }); readonly isFormValid = computed(() => { return this.questionForm?.valid ?? false; }); ngOnInit(): void { // Initialize form this.initializeForm(); // Load categories this.categoryService.getCategories().subscribe(); // Check if we're in edit mode this.route.params.subscribe(params => { const id = params['id']; if (id) { // Defer signal updates to avoid ExpressionChangedAfterItHasBeenCheckedError setTimeout(() => { this.isEditMode.set(true); this.questionId.set(id); this.loadQuestion(id); }); } }); // Watch for question type changes this.questionForm.get('questionType')?.valueChanges.subscribe((type: QuestionType) => { this.onQuestionTypeChange(type); }); } /** * Load existing question data */ private loadQuestion(id: string): void { this.isLoadingQuestion.set(true); this.adminService.getQuestion(id).subscribe({ next: (response) => { this.isLoadingQuestion.set(false); this.populateForm(response.data); }, error: (error) => { this.isLoadingQuestion.set(false); console.error('Error loading question:', error); // Redirect back if question not found this.router.navigate(['/admin/questions']); } }); } /** * Populate form with existing question data */ private populateForm(question: Question): void { // Clear existing options this.optionsArray.clear(); // Populate basic fields this.questionForm.patchValue({ questionText: question.questionText, questionType: question.questionType, categoryId: question.categoryId, difficulty: question.difficulty, correctAnswer: Array.isArray(question.correctAnswer) ? question.correctAnswer[0] : question.correctAnswer, explanation: question.explanation, points: question.points, tags: question.tags || [], isPublic: question.isPublic, isGuestAccessible: question.isPublic // Map isPublic to isGuestAccessible }); // Populate options for multiple choice if (question.questionType === 'multiple_choice' && question.options) { question.options.forEach((option: string) => { this.optionsArray.push(this.createOption(option)); }); } // Trigger question type change to update form state this.onQuestionTypeChange(question.questionType); } /** * Initialize form with all fields */ private initializeForm(): void { this.questionForm = this.fb.group({ questionText: ['', [Validators.required, Validators.minLength(10)]], questionType: ['multiple_choice', Validators.required], categoryId: ['', Validators.required], difficulty: ['medium', Validators.required], options: this.fb.array([ this.createOption(''), this.createOption(''), this.createOption(''), this.createOption('') ]), correctAnswer: ['', Validators.required], explanation: ['', [Validators.required, Validators.minLength(10)]], points: [10, [Validators.required, Validators.min(1), Validators.max(100)]], tags: [[] as string[]], isPublic: [true], isGuestAccessible: [false] }); // Add custom validator for correct answer this.questionForm.setValidators(this.correctAnswerValidator.bind(this)); } /** * Create option form control */ private createOption(value: string = ''): FormGroup { return this.fb.group({ text: [value, Validators.required] }); } /** * Get options form array */ get optionsArray(): FormArray { return this.questionForm.get('options') as FormArray; } /** * Get tags array */ get tagsArray(): string[] { return this.questionForm.get('tags')?.value || []; } /** * Handle question type change */ private onQuestionTypeChange(type: QuestionType): void { const correctAnswerControl = this.questionForm.get('correctAnswer'); if (type === 'multiple_choice') { // Ensure at least 2 options while (this.optionsArray.length < 2) { this.addOption(); } correctAnswerControl?.setValidators([Validators.required]); } else if (type === 'true_false') { // Clear options for True/False this.optionsArray.clear(); correctAnswerControl?.setValidators([Validators.required]); // Set default to True if empty if (!correctAnswerControl?.value) { correctAnswerControl?.setValue('true'); } } else { // Written answer this.optionsArray.clear(); correctAnswerControl?.setValidators([Validators.required, Validators.minLength(1)]); } correctAnswerControl?.updateValueAndValidity(); this.questionForm.updateValueAndValidity(); } /** * Add new option */ addOption(): void { if (this.optionsArray.length < 10) { this.optionsArray.push(this.createOption('')); } } /** * Remove option at index */ removeOption(index: number): void { if (this.optionsArray.length > 2) { this.optionsArray.removeAt(index); // Clear correct answer if it matches the removed option const correctAnswer = this.questionForm.get('correctAnswer')?.value; const removedOption = this.optionsArray.at(index)?.get('text')?.value; if (correctAnswer === removedOption) { this.questionForm.get('correctAnswer')?.setValue(''); } } } /** * Add tag */ addTag(event: MatChipInputEvent): void { const value = (event.value || '').trim(); const tags = this.tagsArray; if (value && !tags.includes(value)) { this.questionForm.get('tags')?.setValue([...tags, value]); } event.chipInput!.clear(); } /** * Remove tag */ removeTag(tag: string): void { const tags = this.tagsArray; const index = tags.indexOf(tag); if (index >= 0) { tags.splice(index, 1); this.questionForm.get('tags')?.setValue([...tags]); } } /** * Custom validator for correct answer */ private correctAnswerValidator(control: AbstractControl): ValidationErrors | null { const formGroup = control as FormGroup; const questionType = formGroup.get('questionType')?.value; const correctAnswer = formGroup.get('correctAnswer')?.value; const options = formGroup.get('options') as FormArray; if (questionType === 'multiple_choice' && correctAnswer && options) { const optionTexts = options.controls.map(opt => opt.get('text')?.value); const isValid = optionTexts.includes(correctAnswer); if (!isValid) { return { correctAnswerMismatch: true }; } } return null; } /** * Get option text values */ getOptionTexts(): string[] { return this.optionsArray.controls.map(opt => opt.get('text')?.value).filter(text => text.trim() !== ''); } /** * Submit form */ onSubmit(): void { if (this.questionForm.invalid || this.isSubmitting()) { this.markFormGroupTouched(this.questionForm); return; } this.isSubmitting.set(true); const formValue = this.questionForm.value; const questionData: QuestionFormData = { questionText: formValue.questionText, questionType: formValue.questionType, difficulty: formValue.difficulty, categoryId: formValue.categoryId, correctAnswer: formValue.correctAnswer, explanation: formValue.explanation, points: formValue.points || 10, tags: formValue.tags || [], isPublic: formValue.isPublic, isGuestAccessible: formValue.isGuestAccessible }; // Add options for multiple choice if (formValue.questionType === 'multiple_choice') { questionData.options = this.getOptionTexts(); } // Determine if create or update const serviceCall = this.isEditMode() && this.questionId() ? this.adminService.updateQuestion(this.questionId()!, questionData) : this.adminService.createQuestion(questionData); serviceCall.subscribe({ next: (response) => { this.isSubmitting.set(false); this.router.navigate(['/admin/questions']); }, error: (error) => { this.isSubmitting.set(false); console.error(`Error ${this.isEditMode() ? 'updating' : 'creating'} question:`, error); } }); } /** * Cancel and go back */ onCancel(): void { this.router.navigate(['/admin/questions']); } /** * Mark all fields as touched to show validation errors */ private markFormGroupTouched(formGroup: FormGroup | FormArray): void { Object.keys(formGroup.controls).forEach(key => { const control = formGroup.get(key); control?.markAsTouched(); if (control instanceof FormGroup || control instanceof FormArray) { this.markFormGroupTouched(control); } }); } /** * Get error message for field */ getErrorMessage(fieldName: string): string { const control = this.questionForm.get(fieldName); if (!control || !control.errors || !control.touched) { return ''; } if (control.errors['required']) { return `${this.getFieldLabel(fieldName)} is required`; } if (control.errors['minlength']) { return `${this.getFieldLabel(fieldName)} must be at least ${control.errors['minlength'].requiredLength} characters`; } if (control.errors['min']) { return `${this.getFieldLabel(fieldName)} must be at least ${control.errors['min'].min}`; } if (control.errors['max']) { return `${this.getFieldLabel(fieldName)} must be at most ${control.errors['max'].max}`; } return ''; } /** * Get field label for error messages */ private getFieldLabel(fieldName: string): string { const labels: Record = { questionText: 'Question text', questionType: 'Question type', categoryId: 'Category', difficulty: 'Difficulty', correctAnswer: 'Correct answer', explanation: 'Explanation', points: 'Points' }; return labels[fieldName] || fieldName; } /** * Get form-level error message */ getFormError(): string | null { if (this.questionForm.errors?.['correctAnswerMismatch']) { return 'Correct answer must match one of the options'; } return null; } }