475 lines
14 KiB
TypeScript
475 lines
14 KiB
TypeScript
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<string | null>(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<string, string> = {
|
|
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;
|
|
}
|
|
}
|